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", },