From b638d2f0b8c1bd0702ca62884c1439476513e096 Mon Sep 17 00:00:00 2001 From: Kazuma Watanabe Date: Sat, 11 Jan 2025 23:30:29 +0900 Subject: [PATCH] Add support for Terraform v1.10 (#2178) * Bump tflint-plugin-sdk to v0.22.0 * Add `ephemeralasnull` function Follow up of https://github.com/hashicorp/terraform/pull/35363 Follow up of https://github.com/hashicorp/terraform/pull/35652 * Introduce ephemeral input variables Follow up of https://github.com/hashicorp/terraform/pull/35273 Follow up of https://github.com/hashicorp/terraform/pull/35985 Terraform throws an error if you use ephemeral values for the count meta-argument, but TFLint does not. This is because the reason for throwing an error is that plan cannot have ephemeral values, which is not an issue in the context of static analysis. In the future, we can throw an error if we need to match this behavior. * Functions that allow marks must also deal with unknown values Follow up of https://github.com/hashicorp/terraform/pull/35985 * Add `terraform.applying` symbol Follow up of https://github.com/hashicorp/terraform/commit/7c928fc10b2f37370d6dbcc1f6e6015fc53e1606 * Introduce ephemeral resources Follow up of https://github.com/hashicorp/terraform/pull/35727 Follow up of https://github.com/hashicorp/terraform/pull/35728 Ephemeral resource addresses are like resources in that they always resolve to unknown values, but they differ in that they are marked as ephemeral, which can have a subtle effect on the return value of the ephemeralasnull function. * `issensitive` must return unknown for unknown args without `sensitive` Follow up of https://github.com/hashicorp/terraform/pull/36012 * Fix `templatefile` function for unknown/marked values Follow up of https://github.com/hashicorp/terraform/pull/36118 Follow up of https://github.com/hashicorp/terraform/pull/36127 * Update collections to use for-range method Follow up of https://github.com/hashicorp/terraform/pull/35818 * Include context when variable default has nested problem Follow up of https://github.com/hashicorp/terraform/pull/35465 * Allow marked values in dynamic block `for_each` Follow up of https://github.com/hashicorp/hcl/pull/679 Previously, for_each in dynamic blocks did not allow marked values such as sensitive. However, https://github.com/hashicorp/hcl/pull/679 now supports this by propagating the marks to expanded children. The reason behind this is to add a new mark called "ephemeral", so we'll pull the changes to support Terraform 1.10. Note that tfhcl's dynamic block support has incomplete mark propagation since marked values resolve to unknown values. This is because in the past the marked values could not be sent over the wire protocol, and may be fixed in the near future. * Do not return ephemeral values to unsupported plugins Because ephemeral values are likely to contain secrets, return ErrSensitive for plugins that do not support it to prevent unintended disclosure. * Add E2E tests for ephemeral values and marked dynamic blocks * Update Terraform compatibility guide --- docs/developer-guide/api_compatibility.md | 3 + docs/user-guide/compatibility.md | 20 ++- go.mod | 10 +- go.sum | 20 +-- integrationtest/inspection/inspection_test.go | 4 +- .../{sensitive => marked}/.tflint.hcl | 0 integrationtest/inspection/marked/main.tf | 48 +++++ integrationtest/inspection/marked/result.json | 85 +++++++++ integrationtest/inspection/sensitive/main.tf | 17 -- .../inspection/sensitive/result.json | 25 --- plugin/server.go | 6 +- plugin/server_test.go | 46 +++++ terraform/addrs/parse_ref.go | 65 +++++-- terraform/addrs/parse_ref_test.go | 101 +++++++++++ terraform/addrs/resource.go | 11 ++ terraform/addrs/resourcemode_string.go | 12 +- terraform/collections/set.go | 32 ++-- terraform/evaluator.go | 13 +- terraform/evaluator_test.go | 45 +++++ terraform/lang/eval.go | 2 + terraform/lang/eval_test.go | 71 +++++--- terraform/lang/funcs/collection.go | 24 +-- terraform/lang/funcs/collection_test.go | 15 +- terraform/lang/funcs/conversion.go | 64 ++++++- terraform/lang/funcs/conversion_test.go | 166 ++++++++++++++++++ terraform/lang/funcs/encoding.go | 13 +- terraform/lang/funcs/encoding_test.go | 8 +- terraform/lang/funcs/filesystem.go | 73 +++++--- terraform/lang/funcs/filesystem_test.go | 63 +++++++ terraform/lang/funcs/number.go | 23 ++- terraform/lang/funcs/number_test.go | 9 + terraform/lang/funcs/sensitive.go | 13 +- terraform/lang/funcs/sensitive_test.go | 40 ++--- terraform/lang/functions.go | 1 + terraform/lang/functions_test.go | 12 ++ terraform/tfhcl/expand_body.go | 49 ++++-- terraform/tfhcl/expand_body_test.go | 73 ++++++++ terraform/tfhcl/expand_spec.go | 24 ++- terraform/tfhcl/expr_wrap.go | 22 ++- terraform/variable.go | 20 ++- 40 files changed, 1126 insertions(+), 222 deletions(-) rename integrationtest/inspection/{sensitive => marked}/.tflint.hcl (100%) create mode 100644 integrationtest/inspection/marked/main.tf create mode 100644 integrationtest/inspection/marked/result.json delete mode 100644 integrationtest/inspection/sensitive/main.tf delete mode 100644 integrationtest/inspection/sensitive/result.json diff --git a/docs/developer-guide/api_compatibility.md b/docs/developer-guide/api_compatibility.md index 4e33db485..71de8aa53 100644 --- a/docs/developer-guide/api_compatibility.md +++ b/docs/developer-guide/api_compatibility.md @@ -11,3 +11,6 @@ TFLint version: v0.42.0+ - https://github.com/terraform-linters/tflint/pull/1722 - https://github.com/terraform-linters/tflint-plugin-sdk/pull/235 - https://github.com/terraform-linters/tflint-plugin-sdk/pull/239 +- Ephemeral value is introduced in SDK v0.22.0 and TFLint v0.55.0. TFLint returns ErrSensitive instead of ephemeral values to plugins built with SDK v0.21.0. + - https://github.com/terraform-linters/tflint/pull/2178 + - https://github.com/terraform-linters/tflint-plugin-sdk/pull/358 diff --git a/docs/user-guide/compatibility.md b/docs/user-guide/compatibility.md index 5505fb216..ed2a895bb 100644 --- a/docs/user-guide/compatibility.md +++ b/docs/user-guide/compatibility.md @@ -4,7 +4,7 @@ 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.9. +The latest supported version is Terraform v1.10. ## Input Variables @@ -32,7 +32,7 @@ resource "aws_instance" "foo" { } ``` -Sensitive variables are ignored. This is to avoid unintended disclosure. +Sensitive or ephemeral variables are ignored. This is to avoid unintended disclosure. ```hcl variable "instance_type" { @@ -101,7 +101,7 @@ resource "aws_instance" "foo" { } ``` -## The `path.*` and `terraform.workspace` Values +## The `path.*` and `terraform.*` Values TFLint supports [filesystem and workspace info](https://developer.hashicorp.com/terraform/language/expressions/references#filesystem-and-workspace-info). @@ -110,14 +110,20 @@ TFLint supports [filesystem and workspace info](https://developer.hashicorp.com/ - `path.cwd` - `terraform.workspace`. +The [`terraform.applying`](https://developer.hashicorp.com/terraform/language/functions/terraform-applying) always resolves to false. + ## Unsupported Named Values -The values below are state-dependent and cannot be determined statically, so TFLint resolves them to unknown values. +The values below are state-dependent or cannot be determined statically, so TFLint resolves them to unknown values. - `.` +- `resource..` +- `ephemeral..` - `module.` - `data..` -- `self` +- `self.` + +The `ephemeral..` always resolves to an unknown value marked as ephemeral, which is the same in most cases as anything else, but some rules may treat it differently. ## Functions @@ -143,7 +149,9 @@ resource "aws_instance" "dynamic" { } ``` -Similar to support for meta-arguments, some rules may process a dynamic block as-is without expansion. If the `for_each` is unknown, the block will be empty. +Similar to support for meta-arguments, some rules may process a dynamic block as-is without expansion. + +If the `for_each` is unknown, the expanded block will be empty. If the `for_each` is sensitive or ephemeral, TFLint expands dynamic blocks like Terraform does, but the mark does not propagate to children and expressions containing iterators resolve to unknown. This is a backwards compatibility limitation that may be resolved in a future version. ## Modules diff --git a/go.mod b/go.mod index 1aef4051c..ffc1561bc 100644 --- a/go.mod +++ b/go.mod @@ -25,14 +25,14 @@ require ( github.com/sourcegraph/go-lsp v0.0.0-20200429204803-219e11d77f5d github.com/sourcegraph/jsonrpc2 v0.2.0 github.com/spf13/afero v1.11.0 - github.com/terraform-linters/tflint-plugin-sdk v0.21.0 + github.com/terraform-linters/tflint-plugin-sdk v0.22.0 github.com/terraform-linters/tflint-ruleset-terraform v0.10.0 github.com/xeipuuv/gojsonschema v1.2.0 github.com/zclconf/go-cty v1.16.0 github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 github.com/zclconf/go-cty-yaml v1.1.0 golang.org/x/crypto v0.32.0 - golang.org/x/net v0.33.0 + golang.org/x/net v0.34.0 golang.org/x/oauth2 v0.25.0 golang.org/x/text v0.21.0 google.golang.org/grpc v1.69.2 @@ -139,17 +139,17 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 // indirect - golang.org/x/mod v0.20.0 // indirect + golang.org/x/mod v0.22.0 // indirect golang.org/x/sync v0.10.0 // indirect golang.org/x/sys v0.29.0 // indirect golang.org/x/term v0.28.0 // indirect golang.org/x/time v0.5.0 // indirect - golang.org/x/tools v0.23.0 // indirect + golang.org/x/tools v0.29.0 // indirect google.golang.org/api v0.172.0 // indirect google.golang.org/genproto v0.0.0-20240311173647-c811ad7063a7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20241015192408-796eee8c2d53 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 // indirect - google.golang.org/protobuf v1.35.1 // indirect + google.golang.org/protobuf v1.36.2 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.120.1 // indirect diff --git a/go.sum b/go.sum index 17ac82803..10b5b4804 100644 --- a/go.sum +++ b/go.sum @@ -696,8 +696,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/terraform-linters/tflint-plugin-sdk v0.21.0 h1:RoorxuuWh1RuL09PWAmaCKw/hmb9QP5dukGXZiB0fs8= -github.com/terraform-linters/tflint-plugin-sdk v0.21.0/go.mod h1:f7ruoYh44RQvnZRxpWhn8JFkpEVlQFT8wC9MhIF0Rp4= +github.com/terraform-linters/tflint-plugin-sdk v0.22.0 h1:holOVJW0hjf0wkjtnYyPWRooQNp8ETUcKE86rdYkH5U= +github.com/terraform-linters/tflint-plugin-sdk v0.22.0/go.mod h1:Cag3YJjBpHdQzI/limZR+Cj7WYPLTIE61xsCdIXoeUI= github.com/terraform-linters/tflint-ruleset-terraform v0.10.0 h1:L+3K3oGvZe5UdQ9F6PMQ6n69A2+Q11dBSg+5nTvxJi8= github.com/terraform-linters/tflint-ruleset-terraform v0.10.0/go.mod h1:wT8nMRBpCg1cIL0Td3LQ3XPcnTTHwBhbCNrFp4jWFrI= github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= @@ -818,8 +818,8 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= -golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= +golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -867,8 +867,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1055,8 +1055,8 @@ golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= -golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= +golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE= +golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1284,8 +1284,8 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= -google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.2 h1:R8FeyR1/eLmkutZOM5CWghmo5itiG9z0ktFlTVLuTmU= +google.golang.org/protobuf v1.36.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/integrationtest/inspection/inspection_test.go b/integrationtest/inspection/inspection_test.go index d43897d7c..e574d138e 100644 --- a/integrationtest/inspection/inspection_test.go +++ b/integrationtest/inspection/inspection_test.go @@ -181,9 +181,9 @@ func TestIntegration(t *testing.T) { Dir: "eval-on-root-context", }, { - Name: "sensitive variable", + Name: "marked values", Command: "tflint --format json", - Dir: "sensitive", + Dir: "marked", }, { Name: "just attributes", diff --git a/integrationtest/inspection/sensitive/.tflint.hcl b/integrationtest/inspection/marked/.tflint.hcl similarity index 100% rename from integrationtest/inspection/sensitive/.tflint.hcl rename to integrationtest/inspection/marked/.tflint.hcl diff --git a/integrationtest/inspection/marked/main.tf b/integrationtest/inspection/marked/main.tf new file mode 100644 index 000000000..457d28607 --- /dev/null +++ b/integrationtest/inspection/marked/main.tf @@ -0,0 +1,48 @@ +variable "no_marked" { + default = "t2.micro" +} + +variable "sensitive" { + sensitive = true + default = "t2.micro" +} + +variable "ephemeral" { + ephemeral = true + default = "t2.micro" +} + +variable "marked_set" { + sensitive = true + default = [true] +} + +resource "aws_instance" "no_marked" { + instance_type = var.no_marked +} + +resource "aws_instance" "sensitive" { + instance_type = var.sensitive +} + +resource "aws_instance" "ephemeral" { + instance_type = var.ephemeral +} + +resource "aws_s3_bucket" "main" { + dynamic "lifecycle_rule" { + for_each = var.marked_set + + content { + enabled = lifecycle_rule.value + } + } + + dynamic "lifecycle_rule" { + for_each = var.marked_set + + content { + enabled = true + } + } +} diff --git a/integrationtest/inspection/marked/result.json b/integrationtest/inspection/marked/result.json new file mode 100644 index 000000000..a082fe9ba --- /dev/null +++ b/integrationtest/inspection/marked/result.json @@ -0,0 +1,85 @@ +{ + "issues": [ + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is t2.micro", + "range": { + "filename": "main.tf", + "start": { + "line": 21, + "column": 19 + }, + "end": { + "line": 21, + "column": 32 + } + }, + "callers": [] + }, + { + "rule": { + "name": "aws_s3_bucket_example_lifecycle_rule", + "severity": "error", + "link": "" + }, + "message": "`lifecycle_rule` block found", + "range": { + "filename": "main.tf", + "start": { + "line": 33, + "column": 3 + }, + "end": { + "line": 33, + "column": 27 + } + }, + "callers": [] + }, + { + "rule": { + "name": "aws_s3_bucket_example_lifecycle_rule", + "severity": "error", + "link": "" + }, + "message": "`lifecycle_rule` block found", + "range": { + "filename": "main.tf", + "start": { + "line": 41, + "column": 3 + }, + "end": { + "line": 41, + "column": 27 + } + }, + "callers": [] + }, + { + "rule": { + "name": "aws_s3_bucket_example_lifecycle_rule", + "severity": "error", + "link": "" + }, + "message": "`enabled` attribute found: true", + "range": { + "filename": "main.tf", + "start": { + "line": 45, + "column": 17 + }, + "end": { + "line": 45, + "column": 21 + } + }, + "callers": [] + } + ], + "errors": [] +} diff --git a/integrationtest/inspection/sensitive/main.tf b/integrationtest/inspection/sensitive/main.tf deleted file mode 100644 index 2dbd28111..000000000 --- a/integrationtest/inspection/sensitive/main.tf +++ /dev/null @@ -1,17 +0,0 @@ -variable "sensitive" { - sensitive = true - default = "t2.micro" -} - -variable "non_sensitive" { - sensitive = false - default = "t2.micro" -} - -resource "aws_instance" "sensitive" { - instance_type = var.sensitive -} - -resource "aws_instance" "non_sensitive" { - instance_type = var.non_sensitive -} diff --git a/integrationtest/inspection/sensitive/result.json b/integrationtest/inspection/sensitive/result.json deleted file mode 100644 index 09ca15200..000000000 --- a/integrationtest/inspection/sensitive/result.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "issues": [ - { - "rule": { - "name": "aws_instance_example_type", - "severity": "error", - "link": "" - }, - "message": "instance type is t2.micro", - "range": { - "filename": "main.tf", - "start": { - "line": 16, - "column": 19 - }, - "end": { - "line": 16, - "column": 36 - } - }, - "callers": [] - } - ], - "errors": [] -} diff --git a/plugin/server.go b/plugin/server.go index be0e8cfca..2f38e1bdf 100644 --- a/plugin/server.go +++ b/plugin/server.go @@ -10,6 +10,7 @@ import ( hcl "github.com/hashicorp/hcl/v2" "github.com/terraform-linters/tflint-plugin-sdk/hclext" "github.com/terraform-linters/tflint-plugin-sdk/plugin/plugin2host" + "github.com/terraform-linters/tflint-plugin-sdk/terraform/lang/marks" sdk "github.com/terraform-linters/tflint-plugin-sdk/tflint" "github.com/terraform-linters/tflint/terraform" "github.com/terraform-linters/tflint/tflint" @@ -145,7 +146,10 @@ func (s *GRPCServer) EvaluateExpr(expr hcl.Expression, opts sdk.EvaluateExprOpti // SDK v0.16+ introduces client-side handling of unknown/NULL/sensitive values. if s.clientSDKVersion != nil && s.clientSDKVersion.GreaterThanOrEqual(version.Must(version.NewVersion("0.16.0"))) { - return val, nil + // Before SDK v0.22, ephemeral marks are ignored, so retrun ErrSensitive to prevent secrets ​​from being leaked. + if !marks.Contains(val, marks.Ephemeral) || s.clientSDKVersion.GreaterThanOrEqual(version.Must(version.NewVersion("0.22.0"))) { + return val, nil + } } if val.ContainsMarked() { diff --git a/plugin/server_test.go b/plugin/server_test.go index c1482b64a..b93cca0ff 100644 --- a/plugin/server_test.go +++ b/plugin/server_test.go @@ -457,6 +457,11 @@ variable "sensitive" { default = "foo" } +variable "ephemeral" { + ephemeral = true + default = "foo" +} + variable "no_default" {} variable "null" { @@ -472,6 +477,7 @@ variable "foo" { server := NewGRPCServer(runner, rootRunner, runner.Files(), SDKVersion) sdkv15 := version.Must(version.NewVersion("0.15.0")) + sdkv21 := version.Must(version.NewVersion("0.21.0")) // test util functions hclExpr := func(expr string) hcl.Expression { @@ -542,6 +548,15 @@ variable "foo" { return err == nil || !errors.Is(err, sdk.ErrSensitive) }, }, + { + Name: "sensitive value (SDK v0.21)", + Args: func() (hcl.Expression, sdk.EvaluateExprOption) { + return hclExpr(`var.sensitive`), sdk.EvaluateExprOption{WantType: &cty.String, ModuleCtx: sdk.SelfModuleCtxType} + }, + Want: cty.StringVal("foo").Mark(marks.Sensitive), + SDKVersion: sdkv21, + ErrCheck: neverHappend, + }, { Name: "no default", Args: func() (hcl.Expression, sdk.EvaluateExprOption) { @@ -622,6 +637,37 @@ variable "foo" { return err == nil || !errors.Is(err, sdk.ErrNullValue) }, }, + { + Name: "ephemeral value", + Args: func() (hcl.Expression, sdk.EvaluateExprOption) { + return hclExpr(`var.ephemeral`), sdk.EvaluateExprOption{WantType: &cty.String, ModuleCtx: sdk.SelfModuleCtxType} + }, + Want: cty.StringVal("foo").Mark(marks.Ephemeral), + ErrCheck: neverHappend, + }, + { + Name: "ephemeral value (SDK v0.21)", + Args: func() (hcl.Expression, sdk.EvaluateExprOption) { + return hclExpr(`var.ephemeral`), sdk.EvaluateExprOption{WantType: &cty.String, ModuleCtx: sdk.SelfModuleCtxType} + }, + Want: cty.NullVal(cty.NilType), + SDKVersion: sdkv21, + ErrCheck: func(err error) bool { + return err == nil || !errors.Is(err, sdk.ErrSensitive) + }, + }, + { + Name: "ephemeral value in object (SDK v0.21)", + Args: func() (hcl.Expression, sdk.EvaluateExprOption) { + ty := cty.Object(map[string]cty.Type{"value": cty.String}) + return hclExpr(`{ value = var.ephemeral }`), sdk.EvaluateExprOption{WantType: &ty, ModuleCtx: sdk.SelfModuleCtxType} + }, + Want: cty.NullVal(cty.NilType), + SDKVersion: sdkv21, + ErrCheck: func(err error) bool { + return err == nil || !errors.Is(err, sdk.ErrSensitive) + }, + }, } for _, test := range tests { diff --git a/terraform/addrs/parse_ref.go b/terraform/addrs/parse_ref.go index 76c576bf3..804116c5b 100644 --- a/terraform/addrs/parse_ref.go +++ b/terraform/addrs/parse_ref.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + package addrs import ( @@ -126,6 +129,19 @@ func parseRef(traversal hcl.Traversal) (*Reference, hcl.Diagnostics) { remain := traversal[1:] // trim off "resource" so we can use our shared resource reference parser return parseResourceRef(ManagedResourceMode, rootRange, remain) + case "ephemeral": + if len(traversal) < 3 { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid reference", + Detail: `The "ephemeral" object must be followed by two attribute names: the ephemeral resource type and the resource name.`, + Subject: traversal.SourceRange().Ptr(), + }) + return nil, diags + } + remain := traversal[1:] // trim off "ephemeral" so we can use our shared resource reference parser + return parseResourceRef(EphemeralResourceMode, rootRange, remain) + case "local": name, rng, remain, diags := parseSingleAttrRef(traversal) return &Reference{ @@ -275,13 +291,40 @@ func parseResourceRef(mode ResourceMode, startRange hcl.Range, traversal hcl.Tra case hcl.TraverseAttr: typeName = tt.Name default: - // If it isn't a TraverseRoot then it must be a "data" reference. - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid reference", - Detail: `The "data" object does not support this operation.`, - Subject: traversal[0].SourceRange().Ptr(), - }) + switch mode { + case ManagedResourceMode: + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid reference", + Detail: `The "resource" object does not support this operation.`, + Subject: traversal[0].SourceRange().Ptr(), + }) + case DataResourceMode: + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid reference", + Detail: `The "data" object does not support this operation.`, + Subject: traversal[0].SourceRange().Ptr(), + }) + case EphemeralResourceMode: + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid reference", + Detail: `The "ephemeral" object does not support this operation.`, + Subject: traversal[0].SourceRange().Ptr(), + }) + default: + // Shouldn't get here because the above should be exhaustive for + // all of the resource modes. But we'll still return a + // minimally-passable error message so that the won't totally + // misbehave if we forget to update this in future. + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid reference", + Detail: `The left operand does not support this operation.`, + Subject: traversal[0].SourceRange().Ptr(), + }) + } return nil, diags } @@ -290,14 +333,16 @@ func parseResourceRef(mode ResourceMode, startRange hcl.Range, traversal hcl.Tra var what string switch mode { case DataResourceMode: - what = "data source" + what = "a data source" + case EphemeralResourceMode: + what = "an ephemeral resource type" default: - what = "resource type" + what = "a resource type" } diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Invalid reference", - Detail: fmt.Sprintf(`A reference to a %s must be followed by at least one attribute access, specifying the resource name.`, what), + Detail: fmt.Sprintf(`A reference to %s must be followed by at least one attribute access, specifying the resource name.`, what), Subject: traversal[1].SourceRange().Ptr(), }) return nil, diags diff --git a/terraform/addrs/parse_ref_test.go b/terraform/addrs/parse_ref_test.go index 24e33db93..176409ee2 100644 --- a/terraform/addrs/parse_ref_test.go +++ b/terraform/addrs/parse_ref_test.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + package addrs import ( @@ -207,6 +210,104 @@ func TestParseRef(t *testing.T) { `The "data" object must be followed by two attribute names: the data source type and the resource name.`, }, + // ephemeral + { + `ephemeral.external.foo`, + &Reference{ + Subject: Resource{ + Mode: EphemeralResourceMode, + Type: "external", + Name: "foo", + }, + SourceRange: hcl.Range{ + Start: hcl.Pos{Line: 1, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 1, Column: 23, Byte: 22}, + }, + }, + ``, + }, + { + `ephemeral.external.foo.bar`, + &Reference{ + Subject: ResourceInstance{ + Resource: Resource{ + Mode: EphemeralResourceMode, + Type: "external", + Name: "foo", + }, + }, + SourceRange: hcl.Range{ + Start: hcl.Pos{Line: 1, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 1, Column: 23, Byte: 22}, + }, + Remaining: hcl.Traversal{ + hcl.TraverseAttr{ + Name: "bar", + SrcRange: hcl.Range{ + Start: hcl.Pos{Line: 1, Column: 23, Byte: 22}, + End: hcl.Pos{Line: 1, Column: 27, Byte: 26}, + }, + }, + }, + }, + ``, + }, + { + `ephemeral.external.foo["baz"].bar`, + &Reference{ + Subject: ResourceInstance{ + Resource: Resource{ + Mode: EphemeralResourceMode, + Type: "external", + Name: "foo", + }, + Key: StringKey("baz"), + }, + SourceRange: hcl.Range{ + Start: hcl.Pos{Line: 1, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 1, Column: 30, Byte: 29}, + }, + Remaining: hcl.Traversal{ + hcl.TraverseAttr{ + Name: "bar", + SrcRange: hcl.Range{ + Start: hcl.Pos{Line: 1, Column: 30, Byte: 29}, + End: hcl.Pos{Line: 1, Column: 34, Byte: 33}, + }, + }, + }, + }, + ``, + }, + { + `ephemeral.external.foo["baz"]`, + &Reference{ + Subject: ResourceInstance{ + Resource: Resource{ + Mode: EphemeralResourceMode, + Type: "external", + Name: "foo", + }, + Key: StringKey("baz"), + }, + SourceRange: hcl.Range{ + Start: hcl.Pos{Line: 1, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 1, Column: 30, Byte: 29}, + }, + }, + ``, + }, + { + `ephemeral`, + nil, + `The "ephemeral" object must be followed by two attribute names: the ephemeral resource type and the resource name.`, + }, + { + `ephemeral.external`, + nil, + `The "ephemeral" object must be followed by two attribute names: the ephemeral resource type and the resource name.`, + }, + // local { `local.foo`, diff --git a/terraform/addrs/resource.go b/terraform/addrs/resource.go index 6b2f71c42..311a59e74 100644 --- a/terraform/addrs/resource.go +++ b/terraform/addrs/resource.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + package addrs import ( @@ -20,6 +23,8 @@ func (r Resource) String() string { return fmt.Sprintf("%s.%s", r.Type, r.Name) case DataResourceMode: return fmt.Sprintf("data.%s.%s", r.Type, r.Name) + case EphemeralResourceMode: + return fmt.Sprintf("ephemeral.%s.%s", r.Type, r.Name) default: // Should never happen, but we'll return a string here rather than // crashing just in case it does. @@ -51,6 +56,8 @@ func (r ResourceInstance) ContainingResource() Resource { // resource lifecycle has a slightly different address format. type ResourceMode rune +//go:generate go run golang.org/x/tools/cmd/stringer -type ResourceMode + const ( // InvalidResourceMode is the zero value of ResourceMode and is not // a valid resource mode. @@ -63,4 +70,8 @@ const ( // DataResourceMode indicates a data resource, as defined by // "data" blocks in configuration. DataResourceMode ResourceMode = 'D' + + // EphemeralResourceMode indicates an ephemeral resource, as defined by + // "ephemeral" blocks in configuration. + EphemeralResourceMode ResourceMode = 'E' ) diff --git a/terraform/addrs/resourcemode_string.go b/terraform/addrs/resourcemode_string.go index 0b5c33f8e..a2b727a9b 100644 --- a/terraform/addrs/resourcemode_string.go +++ b/terraform/addrs/resourcemode_string.go @@ -11,20 +11,26 @@ func _() { _ = x[InvalidResourceMode-0] _ = x[ManagedResourceMode-77] _ = x[DataResourceMode-68] + _ = x[EphemeralResourceMode-69] } const ( _ResourceMode_name_0 = "InvalidResourceMode" - _ResourceMode_name_1 = "DataResourceMode" + _ResourceMode_name_1 = "DataResourceModeEphemeralResourceMode" _ResourceMode_name_2 = "ManagedResourceMode" ) +var ( + _ResourceMode_index_1 = [...]uint8{0, 16, 37} +) + func (i ResourceMode) String() string { switch { case i == 0: return _ResourceMode_name_0 - case i == 68: - return _ResourceMode_name_1 + case 68 <= i && i <= 69: + i -= 68 + return _ResourceMode_name_1[_ResourceMode_index_1[i]:_ResourceMode_index_1[i+1]] case i == 77: return _ResourceMode_name_2 default: diff --git a/terraform/collections/set.go b/terraform/collections/set.go index 75a2ca67b..ea849b3c7 100644 --- a/terraform/collections/set.go +++ b/terraform/collections/set.go @@ -3,6 +3,8 @@ package collections +import "iter" + // Set represents an unordered set of values of a particular type. // // A caller-provided "key function" defines how to produce a comparable unique @@ -81,8 +83,8 @@ func (s Set[T]) Remove(v T) { delete(s.members, k) } -// Elems exposes the internal underlying map representation of the set -// directly, as a pragmatic compromise for efficient iteration. +// All returns an iterator over the elements of the set, in an unspecified +// order. // // The result of this function is part of the internal state of the set // and so callers MUST NOT modify it. If a caller is using locks to ensure @@ -90,20 +92,24 @@ func (s Set[T]) Remove(v T) { // guarded by the same lock as would be used for other methods that read // data from the set. // -// The only correct use of this function is as part of a "for ... range" -// statement using only the values of the resulting map: +// All returns an iterator over the elements of the set, in an unspecified +// order. // -// for _, elem := range set.Elems() { -// // ... +// for elem := range set.All() { +// // do something with elem // } // -// Do not access or make any assumptions about the keys of the resulting -// map. Their exact values are an implementation detail of the set. -func (s Set[T]) Elems() map[UniqueKey[T]]T { - // This is regrettable but the only viable way to support efficient - // iteration over set members until Go gains support for range - // loops over custom iterator functions. - return s.members +// Modifying the set during iteration causes unspecified results. Modifying +// the set concurrently with advancing the iterator causes undefined behavior +// including possible memory unsafety. +func (s Set[T]) All() iter.Seq[T] { + return func(yield func(T) bool) { + for _, v := range s.members { + if !yield(v) { + return + } + } + } } // Len returns the number of unique elements in the set. diff --git a/terraform/evaluator.go b/terraform/evaluator.go index cc9282c35..675a87263 100644 --- a/terraform/evaluator.go +++ b/terraform/evaluator.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + package terraform import ( @@ -171,10 +174,12 @@ func (d *evaluationData) GetInputVariable(addr addrs.InputVariable, rng hcl.Rang val = cty.UnknownVal(config.Type) } - // Mark if sensitive if config.Sensitive { val = val.Mark(marks.Sensitive) } + if config.Ephemeral { + val = val.Mark(marks.Ephemeral) + } return val, diags } @@ -299,6 +304,10 @@ func (d *evaluationData) GetTerraformAttr(addr addrs.TerraformAttr, rng hcl.Rang workspaceName := d.Meta.Env return cty.StringVal(workspaceName), diags + case "applying": + // terraform.applying always returns false in TFLint + return cty.BoolVal(false).Mark(marks.Ephemeral), nil + case "env": // Prior to Terraform 0.12 there was an attribute "env", which was // an alias name for "workspace". This was deprecated and is now @@ -315,7 +324,7 @@ func (d *evaluationData) GetTerraformAttr(addr addrs.TerraformAttr, rng hcl.Rang diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: `Invalid "terraform" attribute`, - Detail: fmt.Sprintf(`The "terraform" object does not have an attribute named %q. The only supported attribute is terraform.workspace, the name of the currently-selected workspace.`, addr.Name), + Detail: fmt.Sprintf(`The "terraform" object does not have an attribute named %q. The only supported attributes are terraform.workspace, the name of the currently-selected workspace, and terraform.applying, a boolean which is true only during apply.`, addr.Name), Subject: rng.Ptr(), }) return cty.DynamicVal, diags diff --git a/terraform/evaluator_test.go b/terraform/evaluator_test.go index 2d301a653..3faee51b5 100644 --- a/terraform/evaluator_test.go +++ b/terraform/evaluator_test.go @@ -170,6 +170,13 @@ variable "string_var" { want: `cty.StringVal("default")`, errCheck: neverHappend, }, + { + name: "terraform.applying", + expr: expr(`terraform.applying`), + ty: cty.Bool, + want: `cty.False.Mark(marks.Ephemeral)`, + errCheck: neverHappend, + }, { name: "interpolation in string", config: ` @@ -760,6 +767,44 @@ locals { want: `cty.StringVal("foo")`, errCheck: neverHappend, }, + { + name: "sensitive variable", + config: ` +variable "foo" { + sensitive = true + default = "bar" +}`, + expr: expr(`var.foo`), + ty: cty.String, + want: `cty.StringVal("bar").Mark(marks.Sensitive)`, + errCheck: neverHappend, + }, + { + name: "ephemeral variable", + config: ` +variable "foo" { + ephemeral = true + default = "bar" +}`, + expr: expr(`var.foo`), + ty: cty.String, + want: `cty.StringVal("bar").Mark(marks.Ephemeral)`, + errCheck: neverHappend, + }, + { + name: "ephemeral resource", + expr: expr(`ephemeral.aws_secretsmanager_secret_version.db_master.secret_string`), + ty: cty.String, + want: `cty.UnknownVal(cty.String).Mark(marks.Ephemeral)`, + errCheck: neverHappend, + }, + { + name: "ephemeral resource with ephemeralasnull", + expr: expr(`ephemeralasnull(ephemeral.aws_secretsmanager_secret_version.db_master.secret_string)`), + ty: cty.String, + want: `cty.UnknownVal(cty.String)`, + errCheck: neverHappend, + }, } for _, test := range tests { diff --git a/terraform/lang/eval.go b/terraform/lang/eval.go index fbde08a77..323ae0e96 100644 --- a/terraform/lang/eval.go +++ b/terraform/lang/eval.go @@ -5,6 +5,7 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/terraform-linters/tflint-plugin-sdk/hclext" + "github.com/terraform-linters/tflint-plugin-sdk/terraform/lang/marks" "github.com/terraform-linters/tflint/terraform/addrs" "github.com/terraform-linters/tflint/terraform/tfdiags" "github.com/terraform-linters/tflint/terraform/tfhcl" @@ -210,6 +211,7 @@ func (s *Scope) evalContext(refs []*addrs.Reference, selfAddr addrs.Referenceabl // The following are unknown values as they are not supported by TFLint. vals["resource"] = cty.UnknownVal(cty.DynamicPseudoType) + vals["ephemeral"] = cty.UnknownVal(cty.DynamicPseudoType).Mark(marks.Ephemeral) vals["data"] = cty.UnknownVal(cty.DynamicPseudoType) vals["module"] = cty.UnknownVal(cty.DynamicPseudoType) vals["self"] = cty.UnknownVal(cty.DynamicPseudoType) diff --git a/terraform/lang/eval_test.go b/terraform/lang/eval_test.go index 0a6f928c4..803722859 100644 --- a/terraform/lang/eval_test.go +++ b/terraform/lang/eval_test.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/terraform-linters/tflint-plugin-sdk/terraform/lang/marks" "github.com/zclconf/go-cty/cty" ctyjson "github.com/zclconf/go-cty/cty/json" @@ -49,10 +50,11 @@ func TestScopeEvalContext(t *testing.T) { "count": cty.ObjectVal(map[string]cty.Value{ "index": cty.NumberIntVal(0), }), - "resource": cty.DynamicVal, - "data": cty.DynamicVal, - "module": cty.DynamicVal, - "self": cty.DynamicVal, + "resource": cty.DynamicVal, + "ephemeral": cty.DynamicVal.Mark(marks.Ephemeral), + "data": cty.DynamicVal, + "module": cty.DynamicVal, + "self": cty.DynamicVal, }, }, { @@ -61,10 +63,11 @@ func TestScopeEvalContext(t *testing.T) { "each": cty.ObjectVal(map[string]cty.Value{ "key": cty.StringVal("a"), }), - "resource": cty.DynamicVal, - "data": cty.DynamicVal, - "module": cty.DynamicVal, - "self": cty.DynamicVal, + "resource": cty.DynamicVal, + "ephemeral": cty.DynamicVal.Mark(marks.Ephemeral), + "data": cty.DynamicVal, + "module": cty.DynamicVal, + "self": cty.DynamicVal, }, }, { @@ -73,10 +76,11 @@ func TestScopeEvalContext(t *testing.T) { "each": cty.ObjectVal(map[string]cty.Value{ "value": cty.NumberIntVal(1), }), - "resource": cty.DynamicVal, - "data": cty.DynamicVal, - "module": cty.DynamicVal, - "self": cty.DynamicVal, + "resource": cty.DynamicVal, + "ephemeral": cty.DynamicVal.Mark(marks.Ephemeral), + "data": cty.DynamicVal, + "module": cty.DynamicVal, + "self": cty.DynamicVal, }, }, { @@ -85,10 +89,11 @@ func TestScopeEvalContext(t *testing.T) { "local": cty.ObjectVal(map[string]cty.Value{ "foo": cty.StringVal("bar"), }), - "resource": cty.DynamicVal, - "data": cty.DynamicVal, - "module": cty.DynamicVal, - "self": cty.DynamicVal, + "resource": cty.DynamicVal, + "ephemeral": cty.DynamicVal.Mark(marks.Ephemeral), + "data": cty.DynamicVal, + "module": cty.DynamicVal, + "self": cty.DynamicVal, }, }, { @@ -96,6 +101,7 @@ func TestScopeEvalContext(t *testing.T) { map[string]cty.Value{ "null_resource": cty.DynamicVal, "resource": cty.DynamicVal, + "ephemeral": cty.DynamicVal.Mark(marks.Ephemeral), "data": cty.DynamicVal, "module": cty.DynamicVal, "self": cty.DynamicVal, @@ -106,6 +112,7 @@ func TestScopeEvalContext(t *testing.T) { map[string]cty.Value{ "null_resource": cty.DynamicVal, "resource": cty.DynamicVal, + "ephemeral": cty.DynamicVal.Mark(marks.Ephemeral), "data": cty.DynamicVal, "module": cty.DynamicVal, "self": cty.DynamicVal, @@ -116,6 +123,7 @@ func TestScopeEvalContext(t *testing.T) { map[string]cty.Value{ "null_resource": cty.DynamicVal, "resource": cty.DynamicVal, + "ephemeral": cty.DynamicVal.Mark(marks.Ephemeral), "data": cty.DynamicVal, "module": cty.DynamicVal, "self": cty.DynamicVal, @@ -126,6 +134,7 @@ func TestScopeEvalContext(t *testing.T) { map[string]cty.Value{ "null_resource": cty.DynamicVal, "resource": cty.DynamicVal, + "ephemeral": cty.DynamicVal.Mark(marks.Ephemeral), "data": cty.DynamicVal, "module": cty.DynamicVal, "self": cty.DynamicVal, @@ -136,6 +145,7 @@ func TestScopeEvalContext(t *testing.T) { map[string]cty.Value{ "null_resource": cty.DynamicVal, "resource": cty.DynamicVal, + "ephemeral": cty.DynamicVal.Mark(marks.Ephemeral), "data": cty.DynamicVal, "module": cty.DynamicVal, "self": cty.DynamicVal, @@ -146,6 +156,7 @@ func TestScopeEvalContext(t *testing.T) { map[string]cty.Value{ "null_resource": cty.DynamicVal, "resource": cty.DynamicVal, + "ephemeral": cty.DynamicVal.Mark(marks.Ephemeral), "data": cty.DynamicVal, "module": cty.DynamicVal, "self": cty.DynamicVal, @@ -156,6 +167,7 @@ func TestScopeEvalContext(t *testing.T) { map[string]cty.Value{ "null_resource": cty.DynamicVal, "resource": cty.DynamicVal, + "ephemeral": cty.DynamicVal.Mark(marks.Ephemeral), "data": cty.DynamicVal, "module": cty.DynamicVal, "self": cty.DynamicVal, @@ -167,10 +179,11 @@ func TestScopeEvalContext(t *testing.T) { "path": cty.ObjectVal(map[string]cty.Value{ "module": cty.StringVal("foo/bar"), }), - "resource": cty.DynamicVal, - "data": cty.DynamicVal, - "module": cty.DynamicVal, - "self": cty.DynamicVal, + "resource": cty.DynamicVal, + "ephemeral": cty.DynamicVal.Mark(marks.Ephemeral), + "data": cty.DynamicVal, + "module": cty.DynamicVal, + "self": cty.DynamicVal, }, }, { @@ -179,10 +192,11 @@ func TestScopeEvalContext(t *testing.T) { "terraform": cty.ObjectVal(map[string]cty.Value{ "workspace": cty.StringVal("default"), }), - "resource": cty.DynamicVal, - "data": cty.DynamicVal, - "module": cty.DynamicVal, - "self": cty.DynamicVal, + "resource": cty.DynamicVal, + "ephemeral": cty.DynamicVal.Mark(marks.Ephemeral), + "data": cty.DynamicVal, + "module": cty.DynamicVal, + "self": cty.DynamicVal, }, }, { @@ -191,10 +205,11 @@ func TestScopeEvalContext(t *testing.T) { "var": cty.ObjectVal(map[string]cty.Value{ "baz": cty.StringVal("boop"), }), - "resource": cty.DynamicVal, - "data": cty.DynamicVal, - "module": cty.DynamicVal, - "self": cty.DynamicVal, + "resource": cty.DynamicVal, + "ephemeral": cty.DynamicVal.Mark(marks.Ephemeral), + "data": cty.DynamicVal, + "module": cty.DynamicVal, + "self": cty.DynamicVal, }, }, } diff --git a/terraform/lang/funcs/collection.go b/terraform/lang/funcs/collection.go index b6cf0ad10..fc9f0ab92 100644 --- a/terraform/lang/funcs/collection.go +++ b/terraform/lang/funcs/collection.go @@ -1,5 +1,5 @@ // Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 +// SPDX-License-Identifier: BUSL-1.1 package funcs @@ -40,6 +40,7 @@ var LengthFunc = function.New(&function.Spec{ coll := args[0] collTy := args[0].Type() marks := coll.Marks() + switch { case collTy == cty.DynamicPseudoType: return cty.UnknownVal(cty.Number).WithMarks(marks), nil @@ -222,14 +223,16 @@ var IndexFunc = function.New(&function.Spec{ var LookupFunc = function.New(&function.Spec{ Params: []function.Parameter{ { - Name: "inputMap", - Type: cty.DynamicPseudoType, - AllowMarked: true, + Name: "inputMap", + Type: cty.DynamicPseudoType, + AllowMarked: true, + AllowUnknown: true, }, { - Name: "key", - Type: cty.String, - AllowMarked: true, + Name: "key", + Type: cty.String, + AllowMarked: true, + AllowUnknown: true, }, }, VarParam: &function.Parameter{ @@ -276,7 +279,7 @@ var LookupFunc = function.New(&function.Spec{ } }, Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { - var defaultVal cty.Value + defaultVal := cty.NullVal(retType) defaultValueSet := false if len(args) == 3 { @@ -297,12 +300,13 @@ var LookupFunc = function.New(&function.Spec{ if len(keyMarks) > 0 { markses = append(markses, keyMarks) } - lookupKey := keyVal.AsString() - if !mapVar.IsKnown() { + if !(mapVar.IsKnown() && keyVal.IsKnown()) { return cty.UnknownVal(retType).WithMarks(markses...), nil } + lookupKey := keyVal.AsString() + if mapVar.Type().IsObjectType() { if mapVar.Type().HasAttribute(lookupKey) { return mapVar.GetAttr(lookupKey).WithMarks(markses...), nil diff --git a/terraform/lang/funcs/collection_test.go b/terraform/lang/funcs/collection_test.go index daf9c5074..258f7a022 100644 --- a/terraform/lang/funcs/collection_test.go +++ b/terraform/lang/funcs/collection_test.go @@ -1,5 +1,5 @@ // Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 +// SPDX-License-Identifier: BUSL-1.1 package funcs @@ -169,6 +169,10 @@ func TestLength(t *testing.T) { }).Mark("secret"), cty.NumberIntVal(3).Mark("secret"), }, + { // Marked objects return a marked length + cty.UnknownVal(cty.String).Mark("secret"), + cty.UnknownVal(cty.Number).Refine().NotNull().NumberRangeLowerBound(cty.NumberIntVal(0), true).NewValue().Mark("secret"), + }, { // Marks on object attribute values do not propagate cty.ObjectVal(map[string]cty.Value{ "a": cty.StringVal("hello").Mark("a"), @@ -884,6 +888,15 @@ func TestLookup(t *testing.T) { cty.StringVal("beep").Mark("a"), false, }, + { // propagate marks from unknown map + []cty.Value{ + cty.UnknownVal(cty.Map(cty.String)).Mark("a"), + cty.StringVal("boop").Mark("b"), + cty.StringVal("nope"), + }, + cty.UnknownVal(cty.String).Mark("a").Mark("b"), + false, + }, } for _, test := range tests { diff --git a/terraform/lang/funcs/conversion.go b/terraform/lang/funcs/conversion.go index c65849d1b..06ca2c83a 100644 --- a/terraform/lang/funcs/conversion.go +++ b/terraform/lang/funcs/conversion.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + package funcs import ( @@ -33,6 +36,7 @@ func MakeToFunc(wantTy cty.Type) function.Function { AllowNull: true, AllowMarked: true, AllowDynamicType: true, + AllowUnknown: true, }, }, Type: func(args []cty.Value) (cty.Type, error) { @@ -57,8 +61,10 @@ func MakeToFunc(wantTy cty.Type) function.Function { return wantTy, nil }, Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { - // We didn't set "AllowUnknown" on our argument, so it is guaranteed - // to be known here but may still be null. + if !args[0].IsKnown() { + return cty.UnknownVal(retType).WithSameMarks(args[0]), nil + } + ret, err := convert.Convert(args[0], retType) if err != nil { val, _ := args[0].UnmarkDeep() @@ -94,3 +100,57 @@ func MakeToFunc(wantTy cty.Type) function.Function { }, }) } + +// EphemeralAsNullFunc is a cty function that takes a value of any type and +// returns a similar value with any ephemeral-marked values anywhere in the +// structure replaced with a null value of the same type that is not marked +// as ephemeral. +// +// This is intended as a convenience for returning the non-ephemeral parts of +// a partially-ephemeral data structure through an output value that isn't +// ephemeral itself. +var EphemeralAsNullFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "value", + Type: cty.DynamicPseudoType, + AllowDynamicType: true, + AllowUnknown: true, + AllowNull: true, + AllowMarked: true, + }, + }, + Type: func(args []cty.Value) (cty.Type, error) { + // This function always preserves the type of the given argument. + return args[0].Type(), nil + }, + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + return cty.Transform(args[0], func(p cty.Path, v cty.Value) (cty.Value, error) { + _, givenMarks := v.Unmark() + if _, isEphemeral := givenMarks[marks.Ephemeral]; isEphemeral { + // We'll strip the ephemeral mark but retain any other marks + // that might be present on the input. + delete(givenMarks, marks.Ephemeral) + if !v.IsKnown() { + // If the source value is unknown then we must leave it + // unknown because its final type might be more precise + // than the associated type constraint and returning a + // typed null could therefore over-promise on what the + // final result type will be. + // We're deliberately constructing a fresh unknown value + // here, rather than returning the one we were given, + // because we need to discard any refinements that the + // unknown value might be carrying that definitely won't + // be honored when we force the final result to be null. + return cty.UnknownVal(v.Type()).WithMarks(givenMarks), nil + } + return cty.NullVal(v.Type()).WithMarks(givenMarks), nil + } + return v, nil + }) + }, +}) + +func EphemeralAsNull(input cty.Value) (cty.Value, error) { + return EphemeralAsNullFunc.Call([]cty.Value{input}) +} diff --git a/terraform/lang/funcs/conversion_test.go b/terraform/lang/funcs/conversion_test.go index 0727f48c7..e706b1503 100644 --- a/terraform/lang/funcs/conversion_test.go +++ b/terraform/lang/funcs/conversion_test.go @@ -1,10 +1,15 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + package funcs import ( "fmt" "testing" + "github.com/google/go-cmp/cmp" "github.com/terraform-linters/tflint-plugin-sdk/terraform/lang/marks" + "github.com/zclconf/go-cty-debug/ctydebug" "github.com/zclconf/go-cty/cty" ) @@ -175,6 +180,24 @@ func TestTo(t *testing.T) { cty.DynamicVal, `incompatible object type for conversion: attribute "foo" is required`, }, + { + cty.UnknownVal(cty.Object(map[string]cty.Type{"foo": cty.String})).Mark(marks.Ephemeral).Mark("boop"), + cty.Map(cty.String), + cty.UnknownVal(cty.Map(cty.String)).Mark(marks.Ephemeral).Mark("boop"), + ``, + }, + { + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("hello"), + "bar": cty.StringVal("world").Mark("beep"), + }).Mark("boop"), + cty.Map(cty.String), + cty.MapVal(map[string]cty.Value{ + "foo": cty.StringVal("hello"), + "bar": cty.StringVal("world").Mark("beep"), + }).Mark("boop"), + ``, + }, } for _, test := range tests { @@ -200,3 +223,146 @@ func TestTo(t *testing.T) { }) } } + +func TestEphemeralAsNull(t *testing.T) { + tests := []struct { + Input cty.Value + Want cty.Value + }{ + // Simple cases + { + cty.StringVal("127.0.0.1:12654").Mark(marks.Ephemeral), + cty.NullVal(cty.String), + }, + { + cty.StringVal("hello"), + cty.StringVal("hello"), + }, + { + // Unknown values stay unknown because an unknown value with + // an imprecise type constraint is allowed to take on a more + // precise type in later phases, but known values (even if null) + // should not. We do know that the final known result definitely + // won't be ephemeral, though. + cty.UnknownVal(cty.String).Mark(marks.Ephemeral), + cty.UnknownVal(cty.String), + }, + { + cty.UnknownVal(cty.String), + cty.UnknownVal(cty.String), + }, + { + // Unknown value refinements should be discarded when unmarking, + // both because we know our final value is going to be null + // anyway and because an ephemeral value is not required to + // have consistent refinements between the plan and apply phases. + cty.UnknownVal(cty.String).RefineNotNull().Mark(marks.Ephemeral), + cty.UnknownVal(cty.String), + }, + { + // Refinements must be preserved for non-ephemeral values, though. + cty.UnknownVal(cty.String).RefineNotNull(), + cty.UnknownVal(cty.String).RefineNotNull(), + }, + + // Should preserve other marks in all cases + { + cty.StringVal("127.0.0.1:12654").Mark(marks.Ephemeral).Mark(marks.Sensitive), + cty.NullVal(cty.String).Mark(marks.Sensitive), + }, + { + cty.StringVal("hello").Mark(marks.Sensitive), + cty.StringVal("hello").Mark(marks.Sensitive), + }, + { + cty.UnknownVal(cty.String).Mark(marks.Ephemeral).Mark(marks.Sensitive), + cty.UnknownVal(cty.String).Mark(marks.Sensitive), + }, + { + cty.UnknownVal(cty.String).Mark(marks.Sensitive), + cty.UnknownVal(cty.String).Mark(marks.Sensitive), + }, + { + cty.UnknownVal(cty.String).RefineNotNull().Mark(marks.Ephemeral).Mark(marks.Sensitive), + cty.UnknownVal(cty.String).Mark(marks.Sensitive), + }, + { + cty.UnknownVal(cty.String).RefineNotNull().Mark(marks.Sensitive), + cty.UnknownVal(cty.String).RefineNotNull().Mark(marks.Sensitive), + }, + + // Nested ephemeral values + { + cty.ListVal([]cty.Value{ + cty.StringVal("127.0.0.1:12654").Mark(marks.Ephemeral), + cty.StringVal("hello"), + }), + cty.ListVal([]cty.Value{ + cty.NullVal(cty.String), + cty.StringVal("hello"), + }), + }, + { + cty.TupleVal([]cty.Value{ + cty.True, + cty.StringVal("127.0.0.1:12654").Mark(marks.Ephemeral), + cty.StringVal("hello"), + }), + cty.TupleVal([]cty.Value{ + cty.True, + cty.NullVal(cty.String), + cty.StringVal("hello"), + }), + }, + { + // Sets can't actually preserve individual element marks, so + // this gets treated as the entire set being ephemeral. + // (That's true of the input value, despite how it's written here, + // not just the result value; cty.SetVal does the simplification + // itself during the construction of the value.) + cty.SetVal([]cty.Value{ + cty.StringVal("127.0.0.1:12654").Mark(marks.Ephemeral), + cty.StringVal("hello"), + }), + cty.NullVal(cty.Set(cty.String)), + }, + { + cty.MapVal(map[string]cty.Value{ + "addr": cty.StringVal("127.0.0.1:12654").Mark(marks.Ephemeral), + "greet": cty.StringVal("hello"), + }), + cty.MapVal(map[string]cty.Value{ + "addr": cty.NullVal(cty.String), + "greet": cty.StringVal("hello"), + }), + }, + { + cty.ObjectVal(map[string]cty.Value{ + "addr": cty.StringVal("127.0.0.1:12654").Mark(marks.Ephemeral), + "greet": cty.StringVal("hello").Mark(marks.Sensitive), + "happy": cty.True, + "both": cty.NumberIntVal(2).WithMarks(cty.NewValueMarks(marks.Sensitive, marks.Ephemeral)), + }), + cty.ObjectVal(map[string]cty.Value{ + "addr": cty.NullVal(cty.String), + "greet": cty.StringVal("hello").Mark(marks.Sensitive), + "happy": cty.True, + "both": cty.NullVal(cty.Number).Mark(marks.Sensitive), + }), + }, + } + + for _, test := range tests { + t.Run(test.Input.GoString(), func(t *testing.T) { + got, err := EphemeralAsNull(test.Input) + if err != nil { + // This function is supposed to be infallible + t.Fatal(err) + } + + if diff := cmp.Diff(test.Want, got, ctydebug.CmpOptions); diff != "" { + t.Errorf("wrong result\n%s", diff) + } + }) + } +} diff --git a/terraform/lang/funcs/encoding.go b/terraform/lang/funcs/encoding.go index 8001fe97d..f92d7df48 100644 --- a/terraform/lang/funcs/encoding.go +++ b/terraform/lang/funcs/encoding.go @@ -1,5 +1,5 @@ // Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 +// SPDX-License-Identifier: BUSL-1.1 package funcs @@ -21,15 +21,20 @@ import ( var Base64DecodeFunc = function.New(&function.Spec{ Params: []function.Parameter{ { - Name: "str", - Type: cty.String, - AllowMarked: true, + Name: "str", + Type: cty.String, + AllowMarked: true, + AllowUnknown: true, }, }, Type: function.StaticReturnType(cty.String), RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { str, strMarks := args[0].Unmark() + if !str.IsKnown() { + return cty.UnknownVal(cty.String).WithMarks(strMarks), nil + } + s := str.AsString() sDec, err := base64.StdEncoding.DecodeString(s) if err != nil { diff --git a/terraform/lang/funcs/encoding_test.go b/terraform/lang/funcs/encoding_test.go index c121c69c7..b146c157e 100644 --- a/terraform/lang/funcs/encoding_test.go +++ b/terraform/lang/funcs/encoding_test.go @@ -1,5 +1,5 @@ // Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 +// SPDX-License-Identifier: BUSL-1.1 package funcs @@ -37,6 +37,12 @@ func TestBase64Decode(t *testing.T) { cty.UnknownVal(cty.String), true, }, + // unknown marked + { + cty.UnknownVal(cty.String).Mark("a").Mark("b"), + cty.UnknownVal(cty.String).RefineNotNull().Mark("a").Mark("b"), + false, + }, } for _, test := range tests { diff --git a/terraform/lang/funcs/filesystem.go b/terraform/lang/funcs/filesystem.go index 1c0fe9617..5233b7150 100644 --- a/terraform/lang/funcs/filesystem.go +++ b/terraform/lang/funcs/filesystem.go @@ -28,15 +28,21 @@ func MakeFileFunc(baseDir string, encBase64 bool) function.Function { return function.New(&function.Spec{ Params: []function.Parameter{ { - Name: "path", - Type: cty.String, - AllowMarked: true, + Name: "path", + Type: cty.String, + AllowMarked: true, + AllowUnknown: true, }, }, Type: function.StaticReturnType(cty.String), RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { pathArg, pathMarks := args[0].Unmark() + + if !pathArg.IsKnown() { + return cty.UnknownVal(cty.String).WithMarks(pathMarks), nil + } + path := pathArg.AsString() src, err := readFileBytes(baseDir, path, pathMarks) if err != nil { @@ -94,13 +100,16 @@ func MakeTemplateFileFunc(baseDir string, funcsCb func() (funcs map[string]funct return function.New(&function.Spec{ Params: []function.Parameter{ { - Name: "path", - Type: cty.String, - AllowMarked: true, + Name: "path", + Type: cty.String, + AllowMarked: true, + AllowUnknown: true, }, { - Name: "vars", - Type: cty.DynamicPseudoType, + Name: "vars", + Type: cty.DynamicPseudoType, + AllowMarked: true, + AllowUnknown: true, }, }, Type: func(args []cty.Value) (cty.Type, error) { @@ -117,20 +126,28 @@ func MakeTemplateFileFunc(baseDir string, funcsCb func() (funcs map[string]funct if err != nil { return cty.DynamicPseudoType, err } + vars, _ := args[1].UnmarkDeep() // This is safe even if args[1] contains unknowns because the HCL // template renderer itself knows how to short-circuit those. - val, err := renderTmpl(expr, args[1]) + val, err := renderTmpl(expr, vars) return val.Type(), err }, Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { pathArg, pathMarks := args[0].Unmark() + + vars, varsMarks := args[1].UnmarkDeep() + + if !pathArg.IsKnown() || !vars.IsKnown() { + return cty.UnknownVal(retType).WithMarks(pathMarks, varsMarks), nil + } + expr, tmplMarks, err := loadTmpl(pathArg.AsString(), pathMarks) if err != nil { return cty.DynamicVal, err } - result, err := renderTmpl(expr, args[1]) - return result.WithMarks(tmplMarks), err + result, err := renderTmpl(expr, vars) + return result.WithMarks(tmplMarks, varsMarks), err }, }) @@ -142,15 +159,21 @@ func MakeFileExistsFunc(baseDir string) function.Function { return function.New(&function.Spec{ Params: []function.Parameter{ { - Name: "path", - Type: cty.String, - AllowMarked: true, + Name: "path", + Type: cty.String, + AllowMarked: true, + AllowUnknown: true, }, }, Type: function.StaticReturnType(cty.Bool), RefineResult: refineNotNull, Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { pathArg, pathMarks := args[0].Unmark() + + if !pathArg.IsKnown() { + return cty.UnknownVal(cty.Bool).WithMarks(pathMarks), nil + } + path := pathArg.AsString() path, err := homedir.Expand(path) if err != nil { @@ -210,24 +233,30 @@ func MakeFileSetFunc(baseDir string) function.Function { return function.New(&function.Spec{ Params: []function.Parameter{ { - Name: "path", - Type: cty.String, - AllowMarked: true, + Name: "path", + Type: cty.String, + AllowMarked: true, + AllowUnknown: true, }, { - Name: "pattern", - Type: cty.String, - AllowMarked: true, + Name: "pattern", + Type: cty.String, + AllowMarked: true, + AllowUnknown: true, }, }, 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() patternArg, patternMarks := args[1].Unmark() - pattern := patternArg.AsString() + if !pathArg.IsKnown() || !patternArg.IsKnown() { + return cty.UnknownVal(retType).WithMarks(pathMarks, patternMarks), nil + } + + path := pathArg.AsString() + pattern := patternArg.AsString() marks := []cty.ValueMarks{pathMarks, patternMarks} if !filepath.IsAbs(path) { diff --git a/terraform/lang/funcs/filesystem_test.go b/terraform/lang/funcs/filesystem_test.go index edb00b850..076fdb3ed 100644 --- a/terraform/lang/funcs/filesystem_test.go +++ b/terraform/lang/funcs/filesystem_test.go @@ -30,6 +30,11 @@ func TestFile(t *testing.T) { cty.StringVal("Hello World"), ``, }, + { + cty.UnknownVal(cty.String).Mark(marks.Sensitive), + cty.UnknownVal(cty.String).RefineNotNull().Mark(marks.Sensitive), + ``, + }, { cty.StringVal("testdata/icon.png"), cty.NilVal, @@ -200,6 +205,44 @@ func TestTemplateFile(t *testing.T) { cty.StringVal("Hello World").Mark(marks.Sensitive), ``, }, + { + cty.StringVal("testdata/list.tmpl").Mark("path"), + cty.ObjectVal(map[string]cty.Value{ + "list": cty.ListVal([]cty.Value{ + cty.StringVal("a"), + cty.StringVal("b").Mark("var"), + cty.StringVal("c"), + }), + }).Mark("vars"), + cty.StringVal(fmt.Sprintf("- a%s- b%s- c%s", LineBreak(), LineBreak(), LineBreak())).Mark("path").Mark("var").Mark("vars"), + ``, + }, + { + cty.StringVal("testdata/list.tmpl").Mark("path"), + cty.UnknownVal(cty.Map(cty.String)), + cty.DynamicVal.Mark("path"), + ``, + }, + { + cty.StringVal("testdata/list.tmpl").Mark("path"), + cty.ObjectVal(map[string]cty.Value{ + "list": cty.ListVal([]cty.Value{ + cty.StringVal("a"), + cty.UnknownVal(cty.String).Mark("var"), + cty.StringVal("c"), + }), + }), + cty.UnknownVal(cty.String).RefineNotNull().Mark("path").Mark("var"), + ``, + }, + { + cty.UnknownVal(cty.String).Mark("path"), + cty.ObjectVal(map[string]cty.Value{ + "key": cty.StringVal("value").Mark("var"), + }), + cty.DynamicVal.Mark("path").Mark("var"), + ``, + }, } funcs := map[string]function.Function{ @@ -288,6 +331,12 @@ func TestFileExists(t *testing.T) { `failed to stat (sensitive value)`, skipIfWin, }, + { + cty.UnknownVal(cty.String).Mark(marks.Sensitive), + cty.UnknownVal(cty.Bool).RefineNotNull().Mark(marks.Sensitive), + ``, + run, + }, } // Ensure "unreadable" directory cannot be listed during the test run @@ -554,6 +603,20 @@ func TestFileSet(t *testing.T) { ``, run, }, + { + cty.StringVal("testdata"), + cty.UnknownVal(cty.String), + cty.UnknownVal(cty.Set(cty.String)).RefineNotNull(), + ``, + run, + }, + { + cty.StringVal("testdata"), + cty.UnknownVal(cty.String).Mark(marks.Sensitive), + cty.UnknownVal(cty.Set(cty.String)).RefineNotNull().Mark(marks.Sensitive), + ``, + run, + }, } for _, test := range tests { diff --git a/terraform/lang/funcs/number.go b/terraform/lang/funcs/number.go index 7da458b3b..17553fe4c 100644 --- a/terraform/lang/funcs/number.go +++ b/terraform/lang/funcs/number.go @@ -1,5 +1,5 @@ // Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 +// SPDX-License-Identifier: BUSL-1.1 package funcs @@ -101,14 +101,16 @@ var SignumFunc = function.New(&function.Spec{ var ParseIntFunc = function.New(&function.Spec{ Params: []function.Parameter{ { - Name: "number", - Type: cty.DynamicPseudoType, - AllowMarked: true, + Name: "number", + Type: cty.DynamicPseudoType, + AllowMarked: true, + AllowUnknown: true, }, { - Name: "base", - Type: cty.Number, - AllowMarked: true, + Name: "base", + Type: cty.Number, + AllowMarked: true, + AllowUnknown: true, }, }, @@ -126,11 +128,16 @@ var ParseIntFunc = function.New(&function.Spec{ var err error numArg, numMarks := args[0].Unmark() + baseArg, baseMarks := args[1].Unmark() + + if !numArg.IsKnown() || !baseArg.IsKnown() { + return cty.UnknownVal(retType).WithMarks(numMarks, baseMarks), nil + } + if err = gocty.FromCtyValue(numArg, &numstr); err != nil { return cty.UnknownVal(cty.String), function.NewArgError(0, err) } - baseArg, baseMarks := args[1].Unmark() if err = gocty.FromCtyValue(baseArg, &base); err != nil { return cty.UnknownVal(cty.Number), function.NewArgError(1, err) } diff --git a/terraform/lang/funcs/number_test.go b/terraform/lang/funcs/number_test.go index 5ebe6304f..0aa8cbd1f 100644 --- a/terraform/lang/funcs/number_test.go +++ b/terraform/lang/funcs/number_test.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + package funcs import ( @@ -214,6 +217,12 @@ func TestParseInt(t *testing.T) { cty.NumberIntVal(128).Mark(marks.Sensitive), ``, }, + { + cty.StringVal("128").Mark(marks.Sensitive), + cty.UnknownVal(cty.Number).Mark(marks.Sensitive), + cty.UnknownVal(cty.Number).RefineNotNull().Mark(marks.Sensitive), + ``, + }, { cty.StringVal("128").Mark("boop"), cty.NumberIntVal(10).Mark(marks.Sensitive), diff --git a/terraform/lang/funcs/sensitive.go b/terraform/lang/funcs/sensitive.go index 905fa3d6c..2c423b7b9 100644 --- a/terraform/lang/funcs/sensitive.go +++ b/terraform/lang/funcs/sensitive.go @@ -28,8 +28,7 @@ var SensitiveFunc = function.New(&function.Spec{ return args[0].Type(), nil }, Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { - val, _ := args[0].Unmark() - return val.Mark(marks.Sensitive), nil + return args[0].Mark(marks.Sensitive), nil }, }) @@ -71,8 +70,14 @@ var IssensitiveFunc = function.New(&function.Spec{ 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 + switch v := args[0]; { + case v.HasMark(marks.Sensitive): + return cty.True, nil + case !v.IsKnown(): + return cty.UnknownVal(cty.Bool), nil + default: + return cty.False, nil + } }, }) diff --git a/terraform/lang/funcs/sensitive_test.go b/terraform/lang/funcs/sensitive_test.go index 7aba097de..13e6a599f 100644 --- a/terraform/lang/funcs/sensitive_test.go +++ b/terraform/lang/funcs/sensitive_test.go @@ -45,14 +45,6 @@ func TestSensitive(t *testing.T) { cty.NumberIntVal(1).Mark(marks.Sensitive), ``, }, - { - // A value with some non-standard mark gets "fixed" to be marked - // with the standard "sensitive" mark. (This situation occurring - // would imply an inconsistency/bug elsewhere, so we're just - // being robust about it here.) - cty.NumberIntVal(1).Mark("bloop"), - ``, - }, { // A value deep already marked is allowed and stays marked, // _and_ we'll also mark the outer collection as sensitive. @@ -81,17 +73,7 @@ func TestSensitive(t *testing.T) { t.Errorf("result is not marked sensitive") } - gotRaw, gotMarks := got.Unmark() - if len(gotMarks) != 1 { - // We're only expecting to have the "sensitive" mark we checked - // above. Any others are an error, even if they happen to - // appear alongside "sensitive". (We might change this rule - // if someday we decide to use marks for some additional - // unrelated thing in Terraform, but currently we assume that - // _all_ marks imply sensitive, and so returning any other - // marks would be confusing.) - t.Errorf("extraneous marks %#v", gotMarks) - } + gotRaw, _ := got.Unmark() // Disregarding shallow marks, the result should have the same // effective value as the input. @@ -184,47 +166,47 @@ func TestNonsensitive(t *testing.T) { func TestIssensitive(t *testing.T) { tests := []struct { Input cty.Value - Sensitive bool + Sensitive cty.Value WantErr string }{ { cty.NumberIntVal(1).Mark(marks.Sensitive), - true, + cty.True, ``, }, { cty.NumberIntVal(1), - false, + cty.False, ``, }, { cty.DynamicVal.Mark(marks.Sensitive), - true, + cty.True, ``, }, { cty.UnknownVal(cty.String).Mark(marks.Sensitive), - true, + cty.True, ``, }, { cty.NullVal(cty.EmptyObject).Mark(marks.Sensitive), - true, + cty.True, ``, }, { cty.NullVal(cty.String), - false, + cty.False, ``, }, { cty.DynamicVal, - false, + cty.UnknownVal(cty.Bool), ``, }, { cty.UnknownVal(cty.String), - false, + cty.UnknownVal(cty.Bool), ``, }, } @@ -245,7 +227,7 @@ func TestIssensitive(t *testing.T) { t.Fatalf("unexpected error: %s", err) } - if (got.True() && !test.Sensitive) || (got.False() && test.Sensitive) { + if !got.RawEquals(test.Sensitive) { t.Errorf("wrong result \ngot: %#v\nwant: %#v", got, test.Sensitive) } }) diff --git a/terraform/lang/functions.go b/terraform/lang/functions.go index ba049ab16..94964db7f 100644 --- a/terraform/lang/functions.go +++ b/terraform/lang/functions.go @@ -92,6 +92,7 @@ func (s *Scope) Functions() map[string]function.Function { "distinct": stdlib.DistinctFunc, "element": stdlib.ElementFunc, "endswith": funcs.EndsWithFunc, + "ephemeralasnull": funcs.EphemeralAsNullFunc, "chunklist": stdlib.ChunklistFunc, "file": funcs.MakeFileFunc(s.BaseDir, false), "fileexists": funcs.MakeFileExistsFunc(s.BaseDir), diff --git a/terraform/lang/functions_test.go b/terraform/lang/functions_test.go index 2f124a9e4..383f38bec 100644 --- a/terraform/lang/functions_test.go +++ b/terraform/lang/functions_test.go @@ -328,6 +328,17 @@ func TestFunctions(t *testing.T) { }, }, + "ephemeralasnull": { + { + `ephemeralasnull(local.ephemeral)`, + cty.NullVal(cty.String), + }, + { + `ephemeralasnull("not ephemeral")`, + cty.StringVal("not ephemeral"), + }, + }, + "file": { { `file("hello.txt")`, @@ -1187,6 +1198,7 @@ func TestFunctions(t *testing.T) { data := &dataForTests{ LocalValues: map[string]cty.Value{ "greeting_template": cty.StringVal("Hello, ${name}!"), + "ephemeral": cty.StringVal("ephemeral").Mark(marks.Ephemeral), }, } scope := &Scope{ diff --git a/terraform/tfhcl/expand_body.go b/terraform/tfhcl/expand_body.go index 3bb83fd8b..3ab0cbf19 100644 --- a/terraform/tfhcl/expand_body.go +++ b/terraform/tfhcl/expand_body.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package tfhcl import ( @@ -15,6 +18,7 @@ type expandBody struct { ctx *hcl.EvalContext dynamicIteration *dynamicIteration // non-nil if we're nested inside a "dynamic" block metaArgIteration *metaArgIteration // non-nil if we're nested inside a block with meta-arguments + valueMarks cty.ValueMarks // These are used with PartialContent to produce a "remaining items" // body to return. They are nil on all bodies fresh out of the transformer. @@ -126,7 +130,7 @@ func (b *expandBody) extendSchema(schema *hcl.BodySchema) *hcl.BodySchema { func (b *expandBody) prepareAttributes(rawAttrs hcl.Attributes) (hcl.Attributes, hcl.Diagnostics) { var diags hcl.Diagnostics - if len(b.hiddenAttrs) == 0 && b.dynamicIteration == nil && b.metaArgIteration == nil { + if len(b.hiddenAttrs) == 0 && b.dynamicIteration == nil && b.metaArgIteration == nil && len(b.valueMarks) == 0 { // Easy path: just pass through the attrs from the original body verbatim return rawAttrs, diags } @@ -143,9 +147,10 @@ func (b *expandBody) prepareAttributes(rawAttrs hcl.Attributes) (hcl.Attributes, if b.dynamicIteration != nil || b.metaArgIteration != nil { attr := *rawAttr // shallow copy so we can mutate it expr := exprWrap{ - Expression: attr.Expr, - di: b.dynamicIteration, - mi: b.metaArgIteration, + Expression: attr.Expr, + di: b.dynamicIteration, + mi: b.metaArgIteration, + resultMarks: b.valueMarks, } // Unlike hcl/ext/dynblock, wrapped expressions are evaluated immediately. // The result is bound to the expression and can be accessed without @@ -161,8 +166,18 @@ func (b *expandBody) prepareAttributes(rawAttrs hcl.Attributes) (hcl.Attributes, } attrs[name] = &attr } else { - // If we have no active iteration then no wrapping is required. - attrs[name] = rawAttr + // If we have no active iteration then no wrapping is required + // unless we have marks to apply. + if len(b.valueMarks) != 0 { + attr := *rawAttr // shallow copy so we can mutate it + attr.Expr = exprWrap{ + Expression: attr.Expr, + resultMarks: b.valueMarks, + } + attrs[name] = &attr + } else { + attrs[name] = rawAttr + } } } return attrs, diags @@ -228,14 +243,16 @@ func (b *expandBody) expandDynamicBlock(schema *hcl.BodySchema, rawBlock *hcl.Bl return hcl.Blocks{}, diags } - if !spec.forEachVal.IsKnown() { + // For dynamic blocks only, it allows marked values + forEachVal, marks := spec.forEachVal.Unmark() + if !forEachVal.IsKnown() { // If for_each is unknown, no blocks are returned return hcl.Blocks{}, diags } var blocks hcl.Blocks - for it := spec.forEachVal.ElementIterator(); it.Next(); { + for it := forEachVal.ElementIterator(); it.Next(); { key, value := it.Element() i := b.dynamicIteration.MakeChild(spec.iteratorName, key, value) @@ -244,7 +261,7 @@ func (b *expandBody) expandDynamicBlock(schema *hcl.BodySchema, rawBlock *hcl.Bl if block != nil { // Attach our new iteration context so that attributes // and other nested blocks can refer to our iterator. - block.Body = b.expandChild(block.Body, i, b.metaArgIteration) + block.Body = b.expandChild(block.Body, i, b.metaArgIteration, marks) blocks = append(blocks, block) } } @@ -278,7 +295,7 @@ func (b *expandBody) expandMetaArgBlock(schema *hcl.BodySchema, rawBlock *hcl.Bl i := MakeCountIteration(cty.NumberIntVal(int64(idx))) expandedBlock := *rawBlock // shallow copy - expandedBlock.Body = b.expandChild(rawBlock.Body, b.dynamicIteration, i) + expandedBlock.Body = b.expandChild(rawBlock.Body, b.dynamicIteration, i, nil) blocks = append(blocks, &expandedBlock) } @@ -299,7 +316,7 @@ func (b *expandBody) expandMetaArgBlock(schema *hcl.BodySchema, rawBlock *hcl.Bl i := MakeForEachIteration(it.Element()) expandedBlock := *rawBlock // shallow copy - expandedBlock.Body = b.expandChild(rawBlock.Body, b.dynamicIteration, i) + expandedBlock.Body = b.expandChild(rawBlock.Body, b.dynamicIteration, i, nil) blocks = append(blocks, &expandedBlock) } @@ -317,15 +334,16 @@ func (b *expandBody) expandStaticBlock(rawBlock *hcl.Block) *hcl.Block { // case it contains expressions that refer to our inherited // iterators, or nested "dynamic" blocks. expandedBlock := *rawBlock - expandedBlock.Body = b.expandChild(rawBlock.Body, b.dynamicIteration, b.metaArgIteration) + expandedBlock.Body = b.expandChild(rawBlock.Body, b.dynamicIteration, b.metaArgIteration, nil) return &expandedBlock } -func (b *expandBody) expandChild(child hcl.Body, i *dynamicIteration, mi *metaArgIteration) hcl.Body { +func (b *expandBody) expandChild(child hcl.Body, i *dynamicIteration, mi *metaArgIteration, valueMarks cty.ValueMarks) hcl.Body { chiCtx := i.EvalContext(mi.EvalContext(b.ctx)) ret := Expand(child, chiCtx) ret.(*expandBody).dynamicIteration = i ret.(*expandBody).metaArgIteration = mi + ret.(*expandBody).valueMarks = valueMarks return ret } @@ -339,3 +357,8 @@ func (b *expandBody) JustAttributes() (hcl.Attributes, hcl.Diagnostics) { func (b *expandBody) MissingItemRange() hcl.Range { return b.original.MissingItemRange() } + +// hcldec.MarkedBody impl +func (b *expandBody) BodyValueMarks() cty.ValueMarks { + return b.valueMarks +} diff --git a/terraform/tfhcl/expand_body_test.go b/terraform/tfhcl/expand_body_test.go index 7fdd6878e..bf85147bc 100644 --- a/terraform/tfhcl/expand_body_test.go +++ b/terraform/tfhcl/expand_body_test.go @@ -1,11 +1,16 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package tfhcl import ( "testing" + "github.com/google/go-cmp/cmp" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hcldec" "github.com/hashicorp/hcl/v2/hcltest" + "github.com/zclconf/go-cty-debug/ctydebug" "github.com/zclconf/go-cty/cty" ) @@ -331,3 +336,71 @@ func TestExpand(t *testing.T) { }) } + +func TestExpandMarkedForEach(t *testing.T) { + srcBody := hcltest.MockBody(&hcl.BodyContent{ + Blocks: hcl.Blocks{ + { + Type: "dynamic", + Labels: []string{"b"}, + LabelRanges: []hcl.Range{{}}, + Body: hcltest.MockBody(&hcl.BodyContent{ + Attributes: hcltest.MockAttrs(map[string]hcl.Expression{ + "for_each": hcltest.MockExprLiteral(cty.TupleVal([]cty.Value{ + cty.StringVal("hey"), + }).Mark("boop")), + "iterator": hcltest.MockExprTraversalSrc("dyn_b"), + }), + Blocks: hcl.Blocks{ + { + Type: "content", + Body: hcltest.MockBody(&hcl.BodyContent{ + Attributes: hcltest.MockAttrs(map[string]hcl.Expression{ + "val0": hcltest.MockExprLiteral(cty.StringVal("static c 1")), + "val1": hcltest.MockExprTraversalSrc("dyn_b.value"), + }), + }), + }, + }, + }), + }, + }, + }) + + // Emulate eval context because iterators are indistinguishable from any resource and effectively resolve to unknown. + ctx := &hcl.EvalContext{ + Variables: map[string]cty.Value{"dyn_b": cty.DynamicVal}, + } + + dynBody := Expand(srcBody, ctx) + + t.Run("Decode", func(t *testing.T) { + decSpec := &hcldec.BlockListSpec{ + TypeName: "b", + Nested: &hcldec.ObjectSpec{ + "val0": &hcldec.AttrSpec{ + Name: "val0", + Type: cty.String, + }, + "val1": &hcldec.AttrSpec{ + Name: "val1", + Type: cty.String, + }, + }, + } + + want := cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "val0": cty.StringVal("static c 1"), + "val1": cty.UnknownVal(cty.String), + }).Mark("boop"), + }) + got, diags := hcldec.Decode(dynBody, decSpec, ctx) + if diags.HasErrors() { + t.Fatalf("unexpected errors\n%s", diags.Error()) + } + if diff := cmp.Diff(want, got, ctydebug.CmpOptions); diff != "" { + t.Errorf("wrong result\n%s", diff) + } + }) +} diff --git a/terraform/tfhcl/expand_spec.go b/terraform/tfhcl/expand_spec.go index 8846c95d0..b42ad253d 100644 --- a/terraform/tfhcl/expand_spec.go +++ b/terraform/tfhcl/expand_spec.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package tfhcl import ( @@ -42,7 +45,9 @@ func (b *expandBody) decodeDynamicSpec(blockS *hcl.BlockHeaderSchema, rawSpec *h eachVal, eachDiags := eachAttr.Expr.Value(b.ctx) diags = append(diags, eachDiags...) - if !eachVal.CanIterateElements() && eachVal.Type() != cty.DynamicPseudoType { + // For dynamic blocks only, it allows marked values + unmarkedEachVal, _ := eachVal.Unmark() + if !unmarkedEachVal.CanIterateElements() && unmarkedEachVal.Type() != cty.DynamicPseudoType { // We skip this error for DynamicPseudoType because that means we either // have a null (which is checked immediately below) or an unknown // (which is handled in the expandBody Content methods). @@ -56,7 +61,7 @@ func (b *expandBody) decodeDynamicSpec(blockS *hcl.BlockHeaderSchema, rawSpec *h }) return nil, diags } - if eachVal.IsNull() { + if unmarkedEachVal.IsNull() { diags = append(diags, &hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Invalid dynamic for_each value", @@ -188,13 +193,26 @@ func (s *expandDynamicSpec) newBlock(i *dynamicIteration, ctx *hcl.EvalContext) return nil, diags } if !labelVal.IsKnown() { + // Unlike hcl/ext/dynblock, if the label is unknown + // it will not return an error and will not append a new block. return nil, diags } if labelVal.IsMarked() { + // This situation is tricky because HCL just works generically + // with marks and so doesn't have any good language to talk about + // the meaning of specific mark types, but yet we cannot allow + // marked values here because the HCL API guarantees that a block's + // labels are always known static constant Go strings. + // Therefore this is a low-quality error message but at least + // better than panicking below when we call labelVal.AsString. + // If this becomes a problem then we could potentially add a new + // option for the public function [Expand] to allow calling + // applications to specify custom label validation functions that + // could then supersede this generic message. diags = append(diags, &hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Invalid dynamic block label", - Detail: "Cannot use a marked value as a dynamic block label.", + Detail: "This value has dynamic marks that make it unsuitable for use as a block label.", Subject: labelExpr.Range().Ptr(), Expression: labelExpr, EvalContext: lCtx, diff --git a/terraform/tfhcl/expr_wrap.go b/terraform/tfhcl/expr_wrap.go index 1843e4aef..4595b46b6 100644 --- a/terraform/tfhcl/expr_wrap.go +++ b/terraform/tfhcl/expr_wrap.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package tfhcl import ( @@ -9,6 +12,13 @@ type exprWrap struct { hcl.Expression di *dynamicIteration mi *metaArgIteration + + // resultMarks is a set of marks that must be applied to whatever + // value results from this expression. We do this whenever a + // dynamic block's for_each expression produced a marked result, + // since in that case any nested expressions inside are treated + // as being derived from that for_each expression. + resultMarks cty.ValueMarks } func (e exprWrap) Variables() []hcl.Traversal { @@ -35,8 +45,14 @@ func (e exprWrap) Variables() []hcl.Traversal { } func (e exprWrap) Value(ctx *hcl.EvalContext) (cty.Value, hcl.Diagnostics) { + if e.di == nil && e.mi == nil { + // If we don't have an active iteration then we can just use the + // given EvalContext directly. + return e.prepareValue(e.Expression.Value(ctx)) + } + extCtx := e.di.EvalContext(e.mi.EvalContext(ctx)) - return e.Expression.Value(extCtx) + return e.prepareValue(e.Expression.Value(extCtx)) } // UnwrapExpression returns the expression being wrapped by this instance. @@ -44,3 +60,7 @@ func (e exprWrap) Value(ctx *hcl.EvalContext) (cty.Value, hcl.Diagnostics) { func (e exprWrap) UnwrapExpression() hcl.Expression { return e.Expression } + +func (e exprWrap) prepareValue(val cty.Value, diags hcl.Diagnostics) (cty.Value, hcl.Diagnostics) { + return val.WithMarks(e.resultMarks), diags +} diff --git a/terraform/variable.go b/terraform/variable.go index 927ab5480..70bf9597c 100644 --- a/terraform/variable.go +++ b/terraform/variable.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + package terraform import ( @@ -8,6 +11,7 @@ import ( "github.com/hashicorp/hcl/v2/gohcl" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/terraform-linters/tflint-plugin-sdk/hclext" + "github.com/terraform-linters/tflint/terraform/tfdiags" "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/convert" ) @@ -24,6 +28,7 @@ type Variable struct { ParsingMode VariableParsingMode Sensitive bool + Ephemeral bool Nullable bool } @@ -51,6 +56,11 @@ func decodeVariableBlock(block *hclext.Block) (*Variable, hcl.Diagnostics) { diags = diags.Extend(valDiags) } + if attr, exists := block.Body.Attributes["ephemeral"]; exists { + valDiags := gohcl.DecodeExpression(attr.Expr, nil, &v.Ephemeral) + diags = diags.Extend(valDiags) + } + if attr, exists := block.Body.Attributes["nullable"]; exists { valDiags := gohcl.DecodeExpression(attr.Expr, nil, &v.Nullable) diags = append(diags, valDiags...) @@ -78,8 +88,11 @@ func decodeVariableBlock(block *hclext.Block) (*Variable, hcl.Diagnostics) { diags = append(diags, &hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Invalid default value for variable", - Detail: fmt.Sprintf("This default value is not compatible with the variable's type constraint: %s.", err), - Subject: attr.Expr.Range().Ptr(), + Detail: fmt.Sprintf( + "This default value is not compatible with the variable's type constraint: %s.", + tfdiags.FormatError(err), + ), + Subject: attr.Expr.Range().Ptr(), }) val = cty.DynamicVal } @@ -229,6 +242,9 @@ var variableBlockSchema = &hclext.BodySchema{ { Name: "sensitive", }, + { + Name: "ephemeral", + }, { Name: "nullable", },