diff --git a/docs/rules/README.md b/docs/rules/README.md index e3e0d7fb..bb159d68 100644 --- a/docs/rules/README.md +++ b/docs/rules/README.md @@ -72,7 +72,7 @@ These rules enforce best practices and naming conventions: |[aws_iam_policy_gov_friendly_arns](aws_iam_policy_gov_friendly_arns.md)|Ensure `iam_policy` resources do not contain `arn:aws:` ARN's|| |[aws_iam_role_policy_gov_friendly_arns](aws_iam_role_policy_gov_friendly_arns.md)|Ensure `iam_role_policy` resources do not contain `arn:aws:` ARN's|| |[aws_lambda_function_deprecated_runtime](aws_lambda_function_deprecated_runtime.md)|Disallow deprecated runtimes for Lambda Function|✔| -|[aws_resource_missing_tags](aws_resource_missing_tags.md)|Require specific tags for all AWS resource types that support them|| +|[aws_resource_tags](aws_resource_tags.md)|Require specific tags for all AWS resource types that support them|| |[aws_s3_bucket_name](aws_s3_bucket_name.md)|Ensures all S3 bucket names match the naming rules|✔| |[aws_provider_missing_default_tags](aws_provider_missing_default_tags.md)|Require specific tags for all AWS providers default tags|| diff --git a/docs/rules/README.md.tmpl b/docs/rules/README.md.tmpl index f197f6fe..aa9b097b 100644 --- a/docs/rules/README.md.tmpl +++ b/docs/rules/README.md.tmpl @@ -72,7 +72,7 @@ These rules enforce best practices and naming conventions: |[aws_iam_policy_gov_friendly_arns](aws_iam_policy_gov_friendly_arns.md)|Ensure `iam_policy` resources do not contain `arn:aws:` ARN's|| |[aws_iam_role_policy_gov_friendly_arns](aws_iam_role_policy_gov_friendly_arns.md)|Ensure `iam_role_policy` resources do not contain `arn:aws:` ARN's|| |[aws_lambda_function_deprecated_runtime](aws_lambda_function_deprecated_runtime.md)|Disallow deprecated runtimes for Lambda Function|✔| -|[aws_resource_missing_tags](aws_resource_missing_tags.md)|Require specific tags for all AWS resource types that support them|| +|[aws_resource_tags](aws_resource_tags.md)|Require specific tags for all AWS resource types that support them|| |[aws_s3_bucket_name](aws_s3_bucket_name.md)|Ensures all S3 bucket names match the naming rules|✔| |[aws_provider_missing_default_tags](aws_provider_missing_default_tags.md)|Require specific tags for all AWS providers default tags|| diff --git a/docs/rules/aws_provider_missing_default_tags.md b/docs/rules/aws_provider_missing_default_tags.md index 613225bd..f4feb3fe 100644 --- a/docs/rules/aws_provider_missing_default_tags.md +++ b/docs/rules/aws_provider_missing_default_tags.md @@ -45,18 +45,15 @@ Notice: The provider is missing the following tags: "Bar", "Foo". (aws_provider_ - Using default tags results in better tagging coverage. The resource missing tags rule needs support to be added for non-standard uses of tags in the provider, for example EC2 root block devices. -Use this rule in conjuction with aws_resource_missing_tags_rule, for example to enforce common tags and +Use this rule in conjuction with aws_resource_tags_rule, for example to enforce common tags and resource specific tags, without duplicating tags. ```hcl -rule "aws_resource_missing_tags" { - enabled = true - tags = [ +rule "aws_resource_tags" { + enabled = true + required = [ "kubernetes.io/cluster/eks", ] - include = [ - "aws_subnet", - ] } rule "aws_provider_missing_default_tags" { diff --git a/docs/rules/aws_resource_missing_tags.md b/docs/rules/aws_resource_missing_tags.md deleted file mode 100644 index f8f31e40..00000000 --- a/docs/rules/aws_resource_missing_tags.md +++ /dev/null @@ -1,76 +0,0 @@ -# aws_resource_missing_tags - -Require specific tags for all AWS resource types that support them. - -## Configuration - -```hcl -rule "aws_resource_missing_tags" { - enabled = true - tags = ["Foo", "Bar"] - exclude = ["aws_autoscaling_group"] # (Optional) Exclude some resource types from tag checks -} -``` - -## Examples - -Most resources use the `tags` attribute with simple `key`=`value` pairs: - -```hcl -resource "aws_instance" "instance" { - instance_type = "m5.large" - tags = { - foo = "Bar" - bar = "Baz" - } -} -``` - -``` -$ tflint -1 issue(s) found: - -Notice: aws_instance.instance is missing the following tags: "Bar", "Foo". (aws_resource_missing_tags) - - on test.tf line 3: - 3: tags = { - 4: foo = "Bar" - 5: bar = "Baz" - 6: } -``` - -Iterators in `dynamic` blocks cannot be expanded, so the tags in the following example will not be detected. - -```hcl -locals { - tags = [ - { - key = "Name", - value = "SomeName", - }, - { - key = "env", - value = "SomeEnv", - }, - ] -} -resource "aws_autoscaling_group" "this" { - dynamic "tag" { - for_each = local.tags - - content { - key = tag.key - value = tag.value - propagate_at_launch = true - } - } -} -``` - -## Why - -You want to set a standardized set of tags for your AWS resources. - -## How To Fix - -For each resource type that supports tags, ensure that each missing tag is present. diff --git a/docs/rules/aws_resource_tags.md b/docs/rules/aws_resource_tags.md new file mode 100644 index 00000000..bd203772 --- /dev/null +++ b/docs/rules/aws_resource_tags.md @@ -0,0 +1,47 @@ +# aws_resource_tags + +Rule for resources tag presence and value validation from prefixed list. + +## Example + +```hcl +rule "aws_resource_tags" { + enabled = true + exclude = ["aws_autoscaling_group"] + required = ["Environment"] + values = { + Department = ["finance", "hr", "payments", "engineering"] + Environment = ["sandbox", "staging", "production"] + } +} + +provider "aws" { + ... + default_tags { + tags = { Environment = "sandbox" } + } +} + +resource "aws_s3_bucket" "bucket" { + ... + tags = { Project: "homepage", Department: "science" } +} +``` + +``` +$ tflint +1 issue(s) found: + +Notice: aws_s3_bucket.bucket Received 'science' for tag 'Department', expected one of 'finance,hr,payments,engineering'. + + on test.tf line 3: + 3: tags = { Project: "homepage", Department = "science" } +``` + +## Why + +Enforce standard tag values across all resources. + +## How To Fix + +Align the provider, resource or autoscaling group tags to the configured expectation. diff --git a/integration/cty-based-eval/.tflint.hcl b/integration/cty-based-eval/.tflint.hcl index d38db6fb..d8c8e227 100644 --- a/integration/cty-based-eval/.tflint.hcl +++ b/integration/cty-based-eval/.tflint.hcl @@ -6,7 +6,7 @@ plugin "aws" { enabled = true } -rule "aws_resource_missing_tags" { - enabled = true - tags = ["Environment", "Name", "Type"] +rule "aws_resource_tags" { + enabled = true + required = ["Environment", "Name", "Type"] } diff --git a/integration/cty-based-eval/result.json b/integration/cty-based-eval/result.json index 96de2ef6..f971b147 100644 --- a/integration/cty-based-eval/result.json +++ b/integration/cty-based-eval/result.json @@ -2,11 +2,11 @@ "issues": [ { "rule": { - "name": "aws_resource_missing_tags", + "name": "aws_resource_tags", "severity": "info", - "link": "https://github.com/terraform-linters/tflint-ruleset-aws/blob/v0.32.0/docs/rules/aws_resource_missing_tags.md" + "link": "https://github.com/terraform-linters/tflint-ruleset-aws/blob/v0.32.0/docs/rules/aws_resource_tags.md" }, - "message": "The resource is missing the following tags: \"Environment\", \"Name\", \"Type\".", + "message": "Tag 'Environment' is required. Tag 'Name' is required. Tag 'Type' is required.", "range": { "filename": "template.tf", "start": { diff --git a/integration/map-attribute/.tflint.hcl b/integration/map-attribute/.tflint.hcl index d38db6fb..d8c8e227 100644 --- a/integration/map-attribute/.tflint.hcl +++ b/integration/map-attribute/.tflint.hcl @@ -6,7 +6,7 @@ plugin "aws" { enabled = true } -rule "aws_resource_missing_tags" { - enabled = true - tags = ["Environment", "Name", "Type"] +rule "aws_resource_tags" { + enabled = true + required = ["Environment", "Name", "Type"] } diff --git a/integration/map-attribute/result.json b/integration/map-attribute/result.json index 912f0cee..ebddce0e 100644 --- a/integration/map-attribute/result.json +++ b/integration/map-attribute/result.json @@ -2,11 +2,11 @@ "issues": [ { "rule": { - "name": "aws_resource_missing_tags", + "name": "aws_resource_tags", "severity": "info", - "link": "https://github.com/terraform-linters/tflint-ruleset-aws/blob/v0.32.0/docs/rules/aws_resource_missing_tags.md" + "link": "https://github.com/terraform-linters/tflint-ruleset-aws/blob/v0.32.0/docs/rules/aws_resource_tags.md" }, - "message": "The resource is missing the following tags: \"Environment\", \"Name\", \"Type\".", + "message": "Tag 'Environment' is required. Tag 'Name' is required. Tag 'Type' is required.", "range": { "filename": "template.tf", "start": { diff --git a/rules/aws_resource_missing_tags_test.go b/rules/aws_resource_missing_tags_test.go deleted file mode 100644 index 9725cf32..00000000 --- a/rules/aws_resource_missing_tags_test.go +++ /dev/null @@ -1,547 +0,0 @@ -package rules - -import ( - "testing" - - hcl "github.com/hashicorp/hcl/v2" - "github.com/stretchr/testify/assert" - "github.com/terraform-linters/tflint-plugin-sdk/helper" -) - -func Test_AwsResourceMissingTags(t *testing.T) { - cases := []struct { - Name string - Content string - Config string - Expected helper.Issues - RaiseErr error - }{ - { - Name: "Wanted tags: Bar,Foo, found: bar,foo", - Content: ` -resource "aws_instance" "ec2_instance" { - instance_type = "t2.micro" - tags = { - foo = "bar" - bar = "baz" - } -}`, - Config: ` -rule "aws_resource_missing_tags" { - enabled = true - tags = ["Foo", "Bar"] -}`, - Expected: helper.Issues{ - { - Rule: NewAwsResourceMissingTagsRule(), - Message: "The resource is missing the following tags: \"Bar\", \"Foo\".", - Range: hcl.Range{ - Filename: "module.tf", - Start: hcl.Pos{Line: 4, Column: 10}, - End: hcl.Pos{Line: 7, Column: 4}, - }, - }, - }, - }, - { - Name: "No tags", - Content: ` -resource "aws_instance" "ec2_instance" { - instance_type = "t2.micro" -}`, - Config: ` -rule "aws_resource_missing_tags" { - enabled = true - tags = ["Foo", "Bar"] -}`, - Expected: helper.Issues{ - { - Rule: NewAwsResourceMissingTagsRule(), - Message: "The resource is missing the following tags: \"Bar\", \"Foo\".", - Range: hcl.Range{ - Filename: "module.tf", - Start: hcl.Pos{Line: 2, Column: 1}, - End: hcl.Pos{Line: 2, Column: 39}, - }, - }, - }, - }, - { - Name: "Tags are correct", - Content: ` -resource "aws_instance" "ec2_instance" { - instance_type = "t2.micro" - tags = { - Foo = "bar" - Bar = "baz" - } -}`, - Config: ` -rule "aws_resource_missing_tags" { - enabled = true - tags = ["Foo", "Bar"] -}`, - Expected: helper.Issues{}, - }, - { - Name: "AutoScaling Group with tag blocks and correct tags", - Content: ` -resource "aws_autoscaling_group" "asg" { - tag { - key = "Foo" - value = "bar" - propagate_at_launch = true - } - tag { - key = "Bar" - value = "baz" - propagate_at_launch = true - } -}`, - Config: ` -rule "aws_resource_missing_tags" { - enabled = true - tags = ["Foo", "Bar"] -}`, - Expected: helper.Issues{}, - }, - { - Name: "AutoScaling Group with tag blocks and incorrect tags", - Content: ` -resource "aws_autoscaling_group" "asg" { - tag { - key = "Foo" - value = "bar" - propagate_at_launch = true - } -}`, - Config: ` -rule "aws_resource_missing_tags" { - enabled = true - tags = ["Foo", "Bar"] -}`, - Expected: helper.Issues{ - { - Rule: NewAwsResourceMissingTagsRule(), - Message: "The resource is missing the following tags: \"Bar\".", - Range: hcl.Range{ - Filename: "module.tf", - Start: hcl.Pos{Line: 2, Column: 1}, - End: hcl.Pos{Line: 2, Column: 39}, - }, - }, - }, - }, - { - Name: "AutoScaling Group with tags attribute and correct tags", - Content: ` -resource "aws_autoscaling_group" "asg" { - tags = [ - { - key = "Foo" - value = "bar" - propagate_at_launch = true - }, - { - key = "Bar" - value = "baz" - propagate_at_launch = true - } - ] -}`, - Config: ` -rule "aws_resource_missing_tags" { - enabled = true - tags = ["Foo", "Bar"] -}`, - Expected: helper.Issues{}, - }, - { - Name: "AutoScaling Group with tags attribute and incorrect tags", - Content: ` -resource "aws_autoscaling_group" "asg" { - tags = [ - { - key = "Foo" - value = "bar" - propagate_at_launch = true - } - ] -}`, - Config: ` -rule "aws_resource_missing_tags" { - enabled = true - tags = ["Foo", "Bar"] -}`, - Expected: helper.Issues{ - { - Rule: NewAwsResourceMissingTagsRule(), - Message: "The resource is missing the following tags: \"Bar\".", - Range: hcl.Range{ - Filename: "module.tf", - Start: hcl.Pos{Line: 3, Column: 10}, - End: hcl.Pos{Line: 9, Column: 4}, - }, - }, - }, - }, - { - Name: "AutoScaling Group excluded from missing tags rule", - Content: ` -resource "aws_autoscaling_group" "asg" { - tags = [ - { - key = "Foo" - value = "bar" - propagate_at_launch = true - } - ] -}`, - Config: ` -rule "aws_resource_missing_tags" { - enabled = true - tags = ["Foo", "Bar"] - exclude = ["aws_autoscaling_group"] -}`, - Expected: helper.Issues{}, - }, - { - Name: "AutoScaling Group with both tag block and tags attribute", - Content: ` -resource "aws_autoscaling_group" "asg" { - tag { - key = "Foo" - value = "bar" - propagate_at_launch = true - } - tags = [ - { - key = "Foo" - value = "bar" - propagate_at_launch = true - } - ] -}`, - Config: ` -rule "aws_resource_missing_tags" { - enabled = true - tags = ["Foo", "Bar"] -}`, - Expected: helper.Issues{ - { - Rule: NewAwsResourceMissingTagsRule(), - Message: "Only tag block or tags attribute may be present, but found both", - Range: hcl.Range{ - Filename: "module.tf", - Start: hcl.Pos{Line: 2, Column: 1}, - End: hcl.Pos{Line: 2, Column: 39}, - }, - }, - }, - }, - { - Name: "AutoScaling Group with no tags", - Content: ` -resource "aws_autoscaling_group" "asg" { -}`, - Config: ` -rule "aws_resource_missing_tags" { - enabled = true - tags = ["Foo", "Bar"] -}`, - Expected: helper.Issues{ - { - Rule: NewAwsResourceMissingTagsRule(), - Message: "The resource is missing the following tags: \"Bar\", \"Foo\".", - Range: hcl.Range{ - Filename: "module.tf", - Start: hcl.Pos{Line: 2, Column: 1}, - End: hcl.Pos{Line: 2, Column: 39}, - }, - }, - }, - }, - { - Name: "Default tags multiple providers", - Content: ` -provider "aws" { - default_tags { - tags = { - "Fooz": "Barz" - "Bazz": "Quxz" - } - } -} - -provider "aws" { - alias = "foo" - default_tags { - tags = { - "Bazz": "Quxz" - "Fooz": "Barz" - } - } -} - -resource "aws_instance" "ec2_instance" { - instance_type = "t2.micro" -} - -resource "aws_instance" "ec2_instance_alias" { - provider = aws.foo - instance_type = "t2.micro" -}`, - Config: ` -rule "aws_resource_missing_tags" { - enabled = true - tags = ["Bazz", "Fooz"] -}`, - Expected: helper.Issues{}, - }, - { - Name: "Default Tags Are to Be overriden by resource specific tags", - Content: ` -provider "aws" { - default_tags { - tags = { - "Foo": "Bar" - } - } -} - -resource "aws_instance" "ec2_instance" { - instance_type = "t2.micro" - tags = { - "Foo": "Bazz" - } -}`, - Config: ` -rule "aws_resource_missing_tags" { - enabled = true - tags = ["Foo"] -}`, - Expected: helper.Issues{}, - }, - { - Name: "Resource specific tags are not needed if default tags are placed", - Content: ` -provider "aws" { - default_tags { - tags = { - "Foo": "Bar" - } - } -} - -resource "aws_instance" "ec2_instance" { - instance_type = "t2.micro" -}`, - Config: ` -rule "aws_resource_missing_tags" { - enabled = true - tags = ["Foo"] -}`, - Expected: helper.Issues{}, - }, - { - Name: "Resource tags in combination with provider level tags", - Content: ` -provider "aws" { - default_tags { - tags = { - "Foo": "Bar" - } - } -} - -resource "aws_instance" "ec2_instance_fail" { - instance_type = "t2.micro" -} - - -resource "aws_instance" "ec2_instance" { - instance_type = "t2.micro" - tags = { - "Bazz": "Quazz" - } -}`, - Config: ` -rule "aws_resource_missing_tags" { - enabled = true - tags = ["Foo", "Bazz"] -}`, - Expected: helper.Issues{ - { - Rule: NewAwsResourceMissingTagsRule(), - Message: "The resource is missing the following tags: \"Bazz\".", - Range: hcl.Range{ - Filename: "module.tf", - Start: hcl.Pos{Line: 10, Column: 1}, - End: hcl.Pos{Line: 10, Column: 44}, - }, - }, - }, - }, - { - Name: "Provider reference existent without tags definition", - Content: `provider "aws" { - alias = "west" - region = "us-west-2" -} - -resource "aws_ssm_parameter" "param" { - provider = aws.west - name = "test" - type = "String" - value = "test" - tags = { - Foo = "Bar" - } -}`, - Config: ` -rule "aws_resource_missing_tags" { - enabled = true - tags = ["Foo"] -}`, - Expected: helper.Issues{}, - }, - { - Name: "Unknown value maps should be silently ignored", - Content: `variable "aws_region" { - default = "us-east-1" - type = string -} - -provider "aws" { - region = "us-east-1" - alias = "foo" - default_tags { - tags = { - Owner = "Owner" - } - } -} - -resource "aws_s3_bucket" "a" { - provider = aws.foo - name = "a" -} - -resource "aws_s3_bucket" "b" { - name = "b" - tags = var.default_tags -} - -variable "default_tags" { - type = map(string) -} - -provider "aws" { - region = "us-east-1" - alias = "bar" - default_tags { - tags = var.default_tags - } -}`, - Config: ` -rule "aws_resource_missing_tags" { - enabled = true - tags = ["Owner"] -}`, - Expected: helper.Issues{}, - }, - { - Name: "Not wholly known tags as value maps should work as long as each key is statically defined", - Content: `variable "owner" { - type = string -} - -variable "project" { - type = string -} - -provider "aws" { - region = "us-east-1" - alias = "foo" - default_tags { - tags = { - Owner = var.owner - Project = var.project - } - } -} - -resource "aws_s3_bucket" "a" { - provider = aws.foo - name = "a" -} - -resource "aws_s3_bucket" "b" { - name = "b" - tags = { - Owner = var.owner - Project = var.project - } -}`, - Config: ` -rule "aws_resource_missing_tags" { - enabled = true - tags = ["Owner", "Project"] -}`, - Expected: helper.Issues{}, - }, - { - // In child modules, the provider declarations are passed implicitly or explicitly from the root module. - // In this case, it is not possible to refer to the provider's default_tags. - // Here, the strategy is to ignore default_tags rather than skip inspection of the tags. - Name: "provider aliases within child modules", - Content: ` -terraform { - required_providers { - aws = { - source = "hashicorp/aws" - configuration_aliases = [ aws.foo ] - } - } -} - -resource "aws_instance" "ec2_instance" { - provider = aws.foo -}`, - Config: ` -rule "aws_resource_missing_tags" { - enabled = true - tags = ["Foo", "Bar"] -}`, - Expected: helper.Issues{ - { - Rule: NewAwsResourceMissingTagsRule(), - Message: "The resource is missing the following tags: \"Bar\", \"Foo\".", - Range: hcl.Range{ - Filename: "module.tf", - Start: hcl.Pos{Line: 11, Column: 1}, - End: hcl.Pos{Line: 11, Column: 39}, - }, - }, - }, - }, - } - - rule := NewAwsResourceMissingTagsRule() - - for _, tc := range cases { - t.Run(tc.Name, func(t *testing.T) { - runner := helper.TestRunner(t, map[string]string{"module.tf": tc.Content, ".tflint.hcl": tc.Config}) - - err := rule.Check(runner) - - if tc.RaiseErr == nil && err != nil { - t.Fatalf("Unexpected error occurred in test \"%s\": %s", tc.Name, err) - } - - assert.Equal(t, tc.RaiseErr, err) - - helper.AssertIssues(t, tc.Expected, runner.Issues) - }) - } -} diff --git a/rules/aws_resource_missing_tags.go b/rules/aws_resource_tags.go similarity index 63% rename from rules/aws_resource_missing_tags.go rename to rules/aws_resource_tags.go index dd6ceec2..01563f4b 100644 --- a/rules/aws_resource_missing_tags.go +++ b/rules/aws_resource_tags.go @@ -2,6 +2,7 @@ package rules import ( "fmt" + "slices" "sort" "strings" @@ -13,125 +14,67 @@ import ( "github.com/terraform-linters/tflint-ruleset-aws/project" "github.com/terraform-linters/tflint-ruleset-aws/rules/tags" "github.com/zclconf/go-cty/cty" - "golang.org/x/exp/slices" ) -// AwsResourceMissingTagsRule checks whether resources are tagged correctly -type AwsResourceMissingTagsRule struct { +const ( + defaultTagsBlockName = "default_tags" + tagsAttributeName = "tags" + tagBlockName = "tag" + providerAttributeName = "provider" + autoScalingGroupResourceName = "aws_autoscaling_group" +) + +// AwsResourceTagsRule checks whether resources are tagged with valid values +type AwsResourceTagsRule struct { tflint.DefaultRule } type awsResourceTagsRuleConfig struct { - Tags []string `hclext:"tags"` - Exclude []string `hclext:"exclude,optional"` + Required []string `hclext:"required,optional"` + Values map[string][]string `hclext:"values,optional"` + Exclude []string `hclext:"exclude,optional"` + Enabled bool `hclext:"enabled,optional"` } -const ( - defaultTagsBlockName = "default_tags" - tagsAttributeName = "tags" - tagBlockName = "tag" - providerAttributeName = "provider" -) +// awsAutoscalingGroupTag is used by go-cty to evaluate tags in aws_autoscaling_group resources +// The type does not need to be public, but its fields do +// https://github.com/zclconf/go-cty/blob/master/docs/gocty.md#converting-to-and-from-structs +type awsAutoscalingGroupTag struct { + Key string `cty:"key"` + Value string `cty:"value"` + PropagateAtLaunch bool `cty:"propagate_at_launch"` +} + +type awsTags map[string]string +type awsProvidersTags map[string]awsTags -// NewAwsResourceMissingTagsRule returns new rules for all resources that support tags -func NewAwsResourceMissingTagsRule() *AwsResourceMissingTagsRule { - return &AwsResourceMissingTagsRule{} +// NewAwsResourceTagsRule returns new rules for all resources that support tags +func NewAwsResourceTagsRule() *AwsResourceTagsRule { + return &AwsResourceTagsRule{} } // Name returns the rule name -func (r *AwsResourceMissingTagsRule) Name() string { - return "aws_resource_missing_tags" +func (r *AwsResourceTagsRule) Name() string { + return "aws_resource_tags" } // Enabled returns whether the rule is enabled by default -func (r *AwsResourceMissingTagsRule) Enabled() bool { +func (r *AwsResourceTagsRule) Enabled() bool { return false } // Severity returns the rule severity -func (r *AwsResourceMissingTagsRule) Severity() tflint.Severity { +func (r *AwsResourceTagsRule) Severity() tflint.Severity { return tflint.NOTICE } // Link returns the rule reference link -func (r *AwsResourceMissingTagsRule) Link() string { +func (r *AwsResourceTagsRule) Link() string { return project.ReferenceLink(r.Name()) } -func (r *AwsResourceMissingTagsRule) getProviderLevelTags(runner tflint.Runner) (map[string][]string, error) { - providerSchema := &hclext.BodySchema{ - Attributes: []hclext.AttributeSchema{ - { - Name: "alias", - Required: false, - }, - }, - Blocks: []hclext.BlockSchema{ - { - Type: defaultTagsBlockName, - Body: &hclext.BodySchema{Attributes: []hclext.AttributeSchema{{Name: tagsAttributeName}}}, - }, - }, - } - - providerBody, err := runner.GetProviderContent("aws", providerSchema, nil) - if err != nil { - return nil, err - } - - // Get provider default tags - allProviderTags := make(map[string][]string) - var providerAlias string - for _, provider := range providerBody.Blocks.OfType(providerAttributeName) { - // Get the alias attribute, in terraform when there is a single aws provider its called "default" - providerAttr, ok := provider.Body.Attributes["alias"] - if !ok { - providerAlias = "default" - } else { - err := runner.EvaluateExpr(providerAttr.Expr, func(alias string) error { - logger.Debug("Walk `%s` provider", providerAlias) - providerAlias = alias - // Init the provider reference even if it doesn't have tags - allProviderTags[alias] = nil - return nil - }, nil) - if err != nil { - return nil, err - } - } - - for _, block := range provider.Body.Blocks { - var providerTags []string - attr, ok := block.Body.Attributes[tagsAttributeName] - if !ok { - continue - } - - err := runner.EvaluateExpr(attr.Expr, func(val cty.Value) error { - keys, known := getKeysForValue(val) - - if !known { - logger.Warn("The missing aws tags rule can only evaluate provided variables, skipping %s.", provider.Labels[0]+"."+providerAlias+"."+defaultTagsBlockName+"."+tagsAttributeName) - return nil - } - - logger.Debug("Walk `%s` provider with tags `%v`", providerAlias, keys) - providerTags = keys - return nil - }, nil) - - if err != nil { - return nil, err - } - - allProviderTags[providerAlias] = providerTags - } - } - return allProviderTags, nil -} - -// Check checks resources for missing tags -func (r *AwsResourceMissingTagsRule) Check(runner tflint.Runner) error { +// Check checks resources for invalid tags +func (r *AwsResourceTagsRule) Check(runner tflint.Runner) error { config := awsResourceTagsRuleConfig{} if err := runner.DecodeRuleConfig(r.Name(), &config); err != nil { return err @@ -155,6 +98,7 @@ func (r *AwsResourceMissingTagsRule) Check(runner tflint.Runner) error { {Name: providerAttributeName}, }, }, nil) + if err != nil { return err } @@ -165,6 +109,7 @@ func (r *AwsResourceMissingTagsRule) Check(runner tflint.Runner) error { for _, resource := range resources.Blocks { providerAlias := "default" + // Override the provider alias if defined if val, ok := resource.Body.Attributes[providerAttributeName]; ok { provider, diagnostics := aws.DecodeProviderConfigRef(val.Expr, "provider") @@ -175,6 +120,8 @@ func (r *AwsResourceMissingTagsRule) Check(runner tflint.Runner) error { providerAlias = provider.Alias } + providerAliasTags := providerTagsMap[providerAlias] + // If the resource has a tags attribute if attribute, okResource := resource.Body.Attributes[tagsAttributeName]; okResource { logger.Debug( @@ -183,13 +130,21 @@ func (r *AwsResourceMissingTagsRule) Check(runner tflint.Runner) error { ) err := runner.EvaluateExpr(attribute.Expr, func(val cty.Value) error { - keys, known := getKeysForValue(val) + knownTags, known := getKnownForValue(val) if !known { - logger.Warn("The missing aws tags rule can only evaluate provided variables, skipping %s.", resource.Labels[0]+"."+resource.Labels[1]+"."+tagsAttributeName) + logger.Warn("The invalid aws tags rule can only evaluate provided variables, skipping %s.", resource.Labels[0]+"."+resource.Labels[1]+"."+tagsAttributeName) return nil } - r.emitIssue(runner, append(providerTagsMap[providerAlias], keys...), config, attribute.Expr.Range()) + // merge the known tags with the provider tags to comply with the implementation + // https://registry.terraform.io/providers/hashicorp/aws/latest/docs/guides/resource-tagging#propagating-tags-to-all-resources + for providerTagKey, providerTagValue := range providerAliasTags { + if _, ok := knownTags[providerTagKey]; !ok { + knownTags[providerTagKey] = providerTagValue + } + } + + r.emitIssue(runner, knownTags, config, attribute.Expr.Range()) return nil }, nil) @@ -198,7 +153,7 @@ func (r *AwsResourceMissingTagsRule) Check(runner tflint.Runner) error { } } else { logger.Debug("Walk `%s` resource", resource.Labels[0]+"."+resource.Labels[1]) - r.emitIssue(runner, providerTagsMap[providerAlias], config, resource.DefRange) + r.emitIssue(runner, providerAliasTags, config, resource.DefRange) } } } @@ -211,18 +166,79 @@ func (r *AwsResourceMissingTagsRule) Check(runner tflint.Runner) error { return nil } -// awsAutoscalingGroupTag is used by go-cty to evaluate tags in aws_autoscaling_group resources -// The type does not need to be public, but its fields do -// https://github.com/zclconf/go-cty/blob/master/docs/gocty.md#converting-to-and-from-structs -type awsAutoscalingGroupTag struct { - Key string `cty:"key"` - Value string `cty:"value"` - PropagateAtLaunch bool `cty:"propagate_at_launch"` +func (r *AwsResourceTagsRule) getProviderLevelTags(runner tflint.Runner) (awsProvidersTags, error) { + providerSchema := &hclext.BodySchema{ + Attributes: []hclext.AttributeSchema{ + { + Name: "alias", + Required: false, + }, + }, + Blocks: []hclext.BlockSchema{ + { + Type: defaultTagsBlockName, + Body: &hclext.BodySchema{Attributes: []hclext.AttributeSchema{{Name: tagsAttributeName}}}, + }, + }, + } + + providerBody, err := runner.GetProviderContent("aws", providerSchema, nil) + if err != nil { + return nil, err + } + + // Get provider default tags + allProviderTags := make(awsProvidersTags) + var providerAlias string + + for _, provider := range providerBody.Blocks.OfType(providerAttributeName) { + // Get the alias attribute, in terraform when there is a single aws provider its called "default" + providerAttr, ok := provider.Body.Attributes["alias"] + if !ok { + providerAlias = "default" + } else { + err := runner.EvaluateExpr(providerAttr.Expr, func(alias string) error { + logger.Debug("Walk `%s` provider", providerAlias) + providerAlias = alias + // Init the provider reference even if it doesn't have tags + allProviderTags[alias] = nil + return nil + }, nil) + if err != nil { + return nil, err + } + } + + for _, block := range provider.Body.Blocks { + attr, ok := block.Body.Attributes[tagsAttributeName] + if !ok { + continue + } + + err := runner.EvaluateExpr(attr.Expr, func(val cty.Value) error { + tags, known := getKnownForValue(val) + + if !known { + logger.Warn("The invalid aws tags rule can only evaluate provided variables, skipping %s.", provider.Labels[0]+"."+providerAlias+"."+defaultTagsBlockName+"."+tagsAttributeName) + return nil + } + + logger.Debug("Walk `%s` provider with tags `%v`", providerAlias, tags) + allProviderTags[providerAlias] = tags + return nil + }, nil) + + if err != nil { + return nil, err + } + } + } + return allProviderTags, nil } // checkAwsAutoScalingGroups handles the special case for tags on AutoScaling Groups // See: https://github.com/terraform-providers/terraform-provider-aws/blob/master/aws/autoscaling_tags.go -func (r *AwsResourceMissingTagsRule) checkAwsAutoScalingGroups(runner tflint.Runner, config awsResourceTagsRuleConfig) error { +func (r *AwsResourceTagsRule) checkAwsAutoScalingGroups(runner tflint.Runner, config awsResourceTagsRuleConfig) error { resourceType := "aws_autoscaling_group" // Skip autoscaling group check if its type is excluded in configuration @@ -236,12 +252,12 @@ func (r *AwsResourceMissingTagsRule) checkAwsAutoScalingGroups(runner tflint.Run } for _, resource := range resources.Blocks { - asgTagBlockTags, tagBlockLocation, err := r.checkAwsAutoScalingGroupsTag(runner, config, resource) + asgTagBlockTags, tagBlockLocation, err := r.checkAwsAutoScalingGroupsTag(runner, resource) if err != nil { return err } - asgTagsAttributeTags, tagsAttributeLocation, err := r.checkAwsAutoScalingGroupsTags(runner, config, resource) + asgTagsAttributeTags, tagsAttributeLocation, err := r.checkAwsAutoScalingGroupsTags(runner, resource) if err != nil { return err } @@ -250,7 +266,7 @@ func (r *AwsResourceMissingTagsRule) checkAwsAutoScalingGroups(runner tflint.Run case len(asgTagBlockTags) > 0 && len(asgTagsAttributeTags) > 0: runner.EmitIssue(r, "Only tag block or tags attribute may be present, but found both", resource.DefRange) case len(asgTagBlockTags) == 0 && len(asgTagsAttributeTags) == 0: - r.emitIssue(runner, []string{}, config, resource.DefRange) + r.emitIssue(runner, map[string]string{}, config, resource.DefRange) case len(asgTagBlockTags) > 0 && len(asgTagsAttributeTags) == 0: tags := asgTagBlockTags location := tagBlockLocation @@ -266,16 +282,17 @@ func (r *AwsResourceMissingTagsRule) checkAwsAutoScalingGroups(runner tflint.Run } // checkAwsAutoScalingGroupsTag checks tag{} blocks on aws_autoscaling_group resources -func (r *AwsResourceMissingTagsRule) checkAwsAutoScalingGroupsTag(runner tflint.Runner, config awsResourceTagsRuleConfig, resourceBlock *hclext.Block) ([]string, hcl.Range, error) { - tags := make([]string, 0) +func (r *AwsResourceTagsRule) checkAwsAutoScalingGroupsTag(runner tflint.Runner, resourceBlock *hclext.Block) (map[string]string, hcl.Range, error) { + tags := map[string]string{} - resources, err := runner.GetResourceContent("aws_autoscaling_group", &hclext.BodySchema{ + resources, err := runner.GetResourceContent(autoScalingGroupResourceName, &hclext.BodySchema{ Blocks: []hclext.BlockSchema{ { Type: tagBlockName, Body: &hclext.BodySchema{ Attributes: []hclext.AttributeSchema{ {Name: "key"}, + {Name: "value"}, }, }, }, @@ -291,14 +308,21 @@ func (r *AwsResourceMissingTagsRule) checkAwsAutoScalingGroupsTag(runner tflint. } for _, tag := range resource.Body.Blocks { - attribute, exists := tag.Body.Attributes["key"] - if !exists { + keyAttribute, keyExists := tag.Body.Attributes["key"] + if !keyExists { return tags, hcl.Range{}, fmt.Errorf(`Did not find expected field "key" in aws_autoscaling_group "%s" starting at line %d`, resource.Labels[0], resource.DefRange.Start.Line) } - err := runner.EvaluateExpr(attribute.Expr, func(key string) error { - tags = append(tags, key) - return nil + valueAttribute, valueExists := tag.Body.Attributes["value"] + if !valueExists { + return tags, hcl.Range{}, fmt.Errorf(`Did not find expected field "value" in aws_autoscaling_group "%s" starting at line %d`, resource.Labels[0], resource.DefRange.Start.Line) + } + + err := runner.EvaluateExpr(keyAttribute.Expr, func(key string) error { + return runner.EvaluateExpr(valueAttribute.Expr, func(value string) error { + tags[key] = value + return nil + }, nil) }, nil) if err != nil { return tags, hcl.Range{}, err @@ -310,10 +334,10 @@ func (r *AwsResourceMissingTagsRule) checkAwsAutoScalingGroupsTag(runner tflint. } // checkAwsAutoScalingGroupsTag checks the tags attribute on aws_autoscaling_group resources -func (r *AwsResourceMissingTagsRule) checkAwsAutoScalingGroupsTags(runner tflint.Runner, config awsResourceTagsRuleConfig, resourceBlock *hclext.Block) ([]string, hcl.Range, error) { - tags := make([]string, 0) +func (r *AwsResourceTagsRule) checkAwsAutoScalingGroupsTags(runner tflint.Runner, resourceBlock *hclext.Block) (map[string]string, hcl.Range, error) { + tags := map[string]string{} - resources, err := runner.GetResourceContent("aws_autoscaling_group", &hclext.BodySchema{ + resources, err := runner.GetResourceContent(autoScalingGroupResourceName, &hclext.BodySchema{ Attributes: []hclext.AttributeSchema{ {Name: tagsAttributeName}, }, @@ -336,7 +360,7 @@ func (r *AwsResourceMissingTagsRule) checkAwsAutoScalingGroupsTags(runner tflint })) err := runner.EvaluateExpr(attribute.Expr, func(asgTags []awsAutoscalingGroupTag) error { for _, tag := range asgTags { - tags = append(tags, tag.Key) + tags[tag.Key] = tag.Value } return nil }, &tflint.EvaluateExprOption{WantType: &wantType}) @@ -350,50 +374,36 @@ func (r *AwsResourceMissingTagsRule) checkAwsAutoScalingGroupsTags(runner tflint return tags, resourceBlock.DefRange, nil } -func (r *AwsResourceMissingTagsRule) emitIssue(runner tflint.Runner, tags []string, config awsResourceTagsRuleConfig, location hcl.Range) { - var missing []string - for _, tag := range config.Tags { - if !slices.Contains(tags, tag) { - missing = append(missing, fmt.Sprintf("%q", tag)) - } - } - if len(missing) > 0 { - sort.Strings(missing) - wanted := strings.Join(missing, ", ") - issue := fmt.Sprintf("The resource is missing the following tags: %s.", wanted) - runner.EmitIssue(r, issue, location) +func (r *AwsResourceTagsRule) emitIssue(runner tflint.Runner, tags map[string]string, config awsResourceTagsRuleConfig, location hcl.Range) { + // sort the tag names for deterministic output + tagsToMatch := sort.StringSlice{} + for tagName := range tags { + tagsToMatch = append(tagsToMatch, tagName) } -} + tagsToMatch.Sort() -func stringInSlice(a string, list []string) bool { - for _, b := range list { - if b == a { - return true - } - } - return false -} + errors := []string{} -// getKeysForValue returns a list of keys from a cty.Value, which is assumed to be a map (or unknown). -// It returns a boolean indicating whether the keys were known. -// If _any_ key is unknown, the entire value is considered unknown, since we can't know if a required tag might be matched by the unknown key. -// Values are entirely ignored and can be unknown. -func getKeysForValue(value cty.Value) (keys []string, known bool) { - if !value.CanIterateElements() || !value.IsKnown() { - return nil, false - } - if value.IsNull() { - return keys, true + // Check the provided tags are valid + for _, tagName := range tagsToMatch { + allowedValues, ok := config.Values[tagName] + // if the tag has a rule configuration then check + if ok { + valueProvided := tags[tagName] + if !slices.Contains(allowedValues, valueProvided) { + errors = append(errors, fmt.Sprintf("Received '%s' for tag '%s', expected one of '%s'.", valueProvided, tagName, strings.Join(allowedValues, ", "))) + } + } } - return keys, !value.ForEachElement(func(key, _ cty.Value) bool { - // If any key is unknown or sensitive, return early as any missing tag could be this unknown key. - if !key.IsKnown() || key.IsNull() || key.IsMarked() { - return true + // Check all required tags are present + for _, requiredTagName := range config.Required { + if !stringInSlice(requiredTagName, tagsToMatch) { + errors = append(errors, fmt.Sprintf("Tag '%s' is required.", requiredTagName)) } + } - keys = append(keys, key.AsString()) - - return false - }) + if len(errors) > 0 { + runner.EmitIssue(r, strings.Join(errors, " "), location) + } } diff --git a/rules/aws_resource_tags_test.go b/rules/aws_resource_tags_test.go new file mode 100644 index 00000000..c8c96fd7 --- /dev/null +++ b/rules/aws_resource_tags_test.go @@ -0,0 +1,465 @@ +package rules + +import ( + "testing" + + hcl "github.com/hashicorp/hcl/v2" + "github.com/stretchr/testify/assert" + "github.com/terraform-linters/tflint-plugin-sdk/helper" +) + +const testTagRule = ` +rule "aws_resource_tags" { + enabled = true + required = ["A", "B"] + values = { A: ["1", "foo"], B: ["2", "bar"] } +} ` + +func Test_AwsResourceTags(t *testing.T) { + cases := []struct { + Name string + Content string + Config string + Expected helper.Issues + RaiseErr error + }{ + // basic assertions + { + Name: "no tags assigned and no rules set", + Content: ` + provider "aws" { } + resource "aws_instance" "ec2_instance" { } + resource "aws_instance" "ec2_instance" { }`, + Config: `rule "aws_resource_tags" { enabled = true }`, + Expected: helper.Issues{}, + }, + { + Name: "no tags assigned", + Content: ` + provider "aws" { } + resource "aws_instance" "ec2_instance" { }`, + Config: testTagRule, + Expected: helper.Issues{ + { + Rule: NewAwsResourceTagsRule(), + Message: "Tag 'A' is required. Tag 'B' is required.", + Range: hcl.Range{ + Filename: "module.tf", + Start: hcl.Pos{Line: 3, Column: 4}, + End: hcl.Pos{Line: 3, Column: 42}, + }, + }, + }, + }, + { + Name: "no tags assigned and rule with only required set", + Content: ` + provider "aws" { } + resource "aws_instance" "ec2_instance" { }`, + Config: `rule "aws_resource_tags" { + required = ["A", "B"] + enabled = true + }`, + Expected: helper.Issues{ + { + Rule: NewAwsResourceTagsRule(), + Message: "Tag 'A' is required. Tag 'B' is required.", + Range: hcl.Range{ + Filename: "module.tf", + Start: hcl.Pos{Line: 3, Column: 4}, + End: hcl.Pos{Line: 3, Column: 42}, + }, + }, + }, + }, + { + Name: "no tags assigned and rule with only values set", + Content: ` + provider "aws" { } + resource "aws_instance" "ec2_instance" { }`, + Config: `rule "aws_resource_tags" { + values = { A: ["1"], B: ["2"] } + enabled = true + }`, + Expected: helper.Issues{}, + }, + { + Name: "resource tag assignment with invalid values", + Content: ` + resource "aws_instance" "ec2_instance" { tags = { A = "0" } } + resource "aws_instance" "ec2_instance" { tags = { B = "0" } }`, + Config: testTagRule, + Expected: helper.Issues{ + { + Rule: NewAwsResourceTagsRule(), + Message: "Received '0' for tag 'A', expected one of '1, foo'. Tag 'B' is required.", + Range: hcl.Range{ + Filename: "module.tf", + Start: hcl.Pos{Line: 2, Column: 52}, + End: hcl.Pos{Line: 2, Column: 63}, + }, + }, + { + Rule: NewAwsResourceTagsRule(), + Message: "Received '0' for tag 'B', expected one of '2, bar'. Tag 'A' is required.", + Range: hcl.Range{ + Filename: "module.tf", + Start: hcl.Pos{Line: 3, Column: 52}, + End: hcl.Pos{Line: 3, Column: 63}, + }, + }, + }, + }, + { + Name: "resource tag assignment with valid values", + Content: `resource "aws_instance" "ec2_instance" { + tags = { A = "1", B = "bar" } + }`, + Config: testTagRule, + Expected: helper.Issues{}, + }, + { + Name: "resource tag assignment with unconfigured tag rule", + Content: `resource "aws_instance" "ec2_instance" { + tags = { A = "1", B = "bar", C = "3" } + }`, + Config: testTagRule, + Expected: helper.Issues{}, + }, + // exclude + { + Name: "resource with invalid tags is excluded via rule", + Content: ` + provider "aws" { } + resource "aws_instance" "ec2_instance_one" { tags = { A: "0", B: "0" } } + resource "aws_instance" "ec2_instance_two" { tags = { A: "xar", B: "zar" } } + resource "aws_s3_bucket" "s3_bucket_one" { tags = { A: "xar", B: "zar" } }`, + Config: ` + rule "aws_resource_tags" { + enabled = true + values = { A: ["1", "foo"], B: ["2", "bar"] } + exclude = ["aws_instance"] + }`, + Expected: helper.Issues{ + { + Rule: NewAwsResourceTagsRule(), + Message: "Received 'xar' for tag 'A', expected one of '1, foo'. Received 'zar' for tag 'B', expected one of '2, bar'.", + Range: hcl.Range{ + Filename: "module.tf", + Start: hcl.Pos{Line: 5, Column: 54}, + End: hcl.Pos{Line: 5, Column: 76}, + }, + }, + }, + }, + // assignment via variables + { + Name: "valid provider tags assigned via variables", + Content: ` + variable "tags" { + type = map(string, string) + default = { A = "1", B = "2" } + } + provider "aws" { + default_tags { tags = var.tags } + } + resource "aws_instance" "ec2_instance_one" {} + resource "aws_instance" "ec2_instance_two" {}`, + Config: testTagRule, + Expected: helper.Issues{}, + }, + { + Name: "invalid provider tags assigned via variables", + Content: ` + variable "tags" { + type = map(string, string) + default = { A = "1", B = "zar" } + } + provider "aws" { + default_tags { tags = var.tags } + } + resource "aws_instance" "ec2_instance_one" {}`, + Config: testTagRule, + Expected: helper.Issues{ + { + Rule: NewAwsResourceTagsRule(), + Message: "Received 'zar' for tag 'B', expected one of '2, bar'.", + Range: hcl.Range{ + Filename: "module.tf", + Start: hcl.Pos{Line: 9, Column: 4}, + End: hcl.Pos{Line: 9, Column: 46}, + }, + }, + }, + }, + { + Name: "invalid resource tags assigned with variables", + Content: ` + variable "tags" { + type = map(string, string) + default = { A = "1", B = "zar" } + } + provider "aws" { } + resource "aws_instance" "ec2_instance_one" { tags = var.tags }`, + Config: testTagRule, + Expected: helper.Issues{ + { + Rule: NewAwsResourceTagsRule(), + Message: "Received 'zar' for tag 'B', expected one of '2, bar'.", + Range: hcl.Range{ + Filename: "module.tf", + Start: hcl.Pos{Line: 7, Column: 56}, + End: hcl.Pos{Line: 7, Column: 64}, + }, + }, + }, + }, + // provider.default_tags + { + Name: "valid provider tags assigned", + Content: ` + provider "aws" { + default_tags { tags = { A = "1", B = "2" } } + } + resource "aws_instance" "ec2_instance_one" {} + resource "aws_instance" "ec2_instance_two" {}`, + Config: testTagRule, + Expected: helper.Issues{}, + }, + { + Name: "valid provider tags assigned and invalid tags assigned to resources", + Content: ` + provider "aws" { + default_tags { tags = { A = "1", B = "2" } } + } + resource "aws_instance" "ec2_instance_one" { tags = { A = "0" }}`, + Config: testTagRule, + Expected: helper.Issues{ + { + Rule: NewAwsResourceTagsRule(), + Message: "Received '0' for tag 'A', expected one of '1, foo'.", + Range: hcl.Range{ + Filename: "module.tf", + Start: hcl.Pos{Line: 5, Column: 56}, + End: hcl.Pos{Line: 5, Column: 67}, + }, + }, + }, + }, + { + Name: "invalid default provider tags assigned and no tags assigned to resource", + Content: ` + provider "aws" { + default_tags { tags = { A = "0", B = "foo" } } + } + resource "aws_s3_bucket" "bucket" {}`, + Config: testTagRule, + Expected: helper.Issues{ + { + Rule: NewAwsResourceTagsRule(), + Message: "Received '0' for tag 'A', expected one of '1, foo'. Received 'foo' for tag 'B', expected one of '2, bar'.", + Range: hcl.Range{ + Filename: "module.tf", + Start: hcl.Pos{Line: 5, Column: 4}, + End: hcl.Pos{Line: 5, Column: 37}, + }, + }, + }, + }, + { + Name: "multiple providers with default tags assigned", + Content: ` + provider "aws" { + alias = "one" + default_tags { tags = { A = "1" } } + } + provider "aws" { + alias = "two" + default_tags { tags = { B = "2" } } + } + resource "aws_instance" "ec2_instance" { provider = aws.one } + resource "aws_instance" "ec2_instance" { provider = aws.two }`, + Config: testTagRule, + Expected: helper.Issues{ + { + Rule: NewAwsResourceTagsRule(), + Message: "Tag 'B' is required.", + Range: hcl.Range{ + Filename: "module.tf", + Start: hcl.Pos{Line: 10, Column: 4}, + End: hcl.Pos{Line: 10, Column: 42}, + }, + }, + { + Rule: NewAwsResourceTagsRule(), + Message: "Tag 'A' is required.", + Range: hcl.Range{ + Filename: "module.tf", + Start: hcl.Pos{Line: 11, Column: 4}, + End: hcl.Pos{Line: 11, Column: 42}, + }, + }, + }, + }, + // aws_autoscaling_group + { + Name: "autoscaling group with no tags", + Content: `resource "aws_autoscaling_group" "asg" {}`, + Config: testTagRule, + Expected: helper.Issues{ + { + Rule: NewAwsResourceTagsRule(), + Message: "Tag 'A' is required. Tag 'B' is required.", + Range: hcl.Range{ + Filename: "module.tf", + Start: hcl.Pos{Line: 1, Column: 1}, + End: hcl.Pos{Line: 1, Column: 39}, + }, + }, + }, + }, + { + Name: "autoscaling group with invalid tags assigned with block syntax", + Content: `resource "aws_autoscaling_group" "asg" { + tag { + key = "A" + value = "2" + propagate_at_launch = true + } + tag { + key = "B" + value = "2" + propagate_at_launch = true + } + }`, + Config: testTagRule, + Expected: helper.Issues{ + { + Rule: NewAwsResourceTagsRule(), + Message: "Received '2' for tag 'A', expected one of '1, foo'.", + Range: hcl.Range{ + Filename: "module.tf", + Start: hcl.Pos{Line: 1, Column: 1}, + End: hcl.Pos{Line: 1, Column: 39}, + }, + }, + }, + }, + { + Name: "autoscaling group with missing tags via block syntax", + Content: `resource "aws_autoscaling_group" "asg" { + tag { + key = "A" + value = "foo" + propagate_at_launch = true + } + }`, + Config: testTagRule, + Expected: helper.Issues{ + { + Rule: NewAwsResourceTagsRule(), + Message: "Tag 'B' is required.", + Range: hcl.Range{ + Filename: "module.tf", + Start: hcl.Pos{Line: 1, Column: 1}, + End: hcl.Pos{Line: 1, Column: 39}, + }, + }, + }, + }, + { + Name: "autoscaling group with missing tags", + Content: `resource "aws_autoscaling_group" "asg" { + tags = [ + { key = "A", value = "foo", propagate_at_launch = true } + ] + }`, + Config: testTagRule, + Expected: helper.Issues{ + { + Rule: NewAwsResourceTagsRule(), + Message: "Tag 'B' is required.", + Range: hcl.Range{ + Filename: "module.tf", + Start: hcl.Pos{Line: 2, Column: 12}, + End: hcl.Pos{Line: 4, Column: 6}, + }, + }, + }, + }, + { + Name: "autoscaling group with valid tags assigned with block syntax", + Content: ` + resource "aws_autoscaling_group" "asg" { + tag { + key = "A" + value = "foo" + } + + tag { + key = "B" + value = "bar" + } + }`, + Config: testTagRule, + Expected: helper.Issues{}, + }, + { + Name: "autoscaling group with valid tag assignment", + Content: ` + resource "aws_autoscaling_group" "asg" { + tags = [ + { key = "A", value = "foo", propagate_at_launch = true }, + { key = "B", value = "bar", propagate_at_launch = true } + ] + }`, + Config: testTagRule, + Expected: helper.Issues{}, + }, + { + Name: "autoscaling group with mixed tag assignment", + Content: ` + resource "aws_autoscaling_group" "asg" { + tag { + key = "A" + value = "foo" + } + + tags = [ + { key = "A", value = "foo", propagate_at_launch = true }, + { key = "B", value = "bar", propagate_at_launch = true } + ] + }`, + Config: testTagRule, + Expected: helper.Issues{ + { + Rule: NewAwsResourceTagsRule(), + Message: "Only tag block or tags attribute may be present, but found both", + Range: hcl.Range{ + Filename: "module.tf", + Start: hcl.Pos{Line: 2, Column: 4}, + End: hcl.Pos{Line: 2, Column: 42}, + }, + }, + }, + }, + } + + rule := NewAwsResourceTagsRule() + + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + runner := helper.TestRunner(t, map[string]string{"module.tf": tc.Content, ".tflint.hcl": tc.Config}) + + err := rule.Check(runner) + + if tc.RaiseErr == nil && err != nil { + t.Fatalf("Unexpected error occurred in test \"%s\": %s", tc.Name, err) + } + + assert.Equal(t, tc.RaiseErr, err) + + helper.AssertIssues(t, tc.Expected, runner.Issues) + }) + } +} diff --git a/rules/provider.go b/rules/provider.go index 4d450b64..e3a1b5af 100644 --- a/rules/provider.go +++ b/rules/provider.go @@ -21,7 +21,6 @@ var manualRules = []tflint.Rule{ NewAwsInstancePreviousTypeRule(), NewAwsMqBrokerInvalidEngineTypeRule(), NewAwsMqConfigurationInvalidEngineTypeRule(), - NewAwsResourceMissingTagsRule(), NewAwsRouteNotSpecifiedTargetRule(), NewAwsRouteSpecifiedMultipleTargetsRule(), NewAwsS3BucketInvalidACLRule(), @@ -40,6 +39,7 @@ var manualRules = []tflint.Rule{ NewAwsSecurityGroupInvalidProtocolRule(), NewAwsSecurityGroupRuleInvalidProtocolRule(), NewAwsProviderMissingDefaultTagsRule(), + NewAwsResourceTagsRule(), } // Rules is a list of all rules diff --git a/rules/utils.go b/rules/utils.go index 5278af4b..df97f3ad 100644 --- a/rules/utils.go +++ b/rules/utils.go @@ -1,5 +1,7 @@ package rules +import "github.com/zclconf/go-cty/cty" + var validElastiCacheNodeTypes = map[string]bool{ // https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/CacheNodes.SupportedTypes.html "cache.t2.micro": true, @@ -99,3 +101,60 @@ var previousElastiCacheNodeTypes = map[string]bool{ "r3": true, "t1": true, } + +// getKeysForValue returns a list of keys from a cty.Value, which is assumed to be a map (or unknown). +// It returns a boolean indicating whether the keys were known. +// If _any_ key is unknown, the entire value is considered unknown, since we can't know if a required tag might be matched by the unknown key. +// Values are entirely ignored and can be unknown. +func getKeysForValue(value cty.Value) (keys []string, known bool) { + if !value.CanIterateElements() || !value.IsKnown() { + return nil, false + } + if value.IsNull() { + return keys, true + } + return keys, !value.ForEachElement(func(key, _ cty.Value) bool { + // If any key is unknown or sensitive, return early as any missing tag could be this unknown key. + if !key.IsKnown() || key.IsNull() || key.IsMarked() { + return true + } + keys = append(keys, key.AsString()) + return false + }) +} + +func stringInSlice(a string, list []string) bool { + for _, b := range list { + if b == a { + return true + } + } + return false +} + +func getKnownForValue(value cty.Value) (map[string]string, bool) { + tags := map[string]string{} + + if !value.CanIterateElements() || !value.IsKnown() { + return nil, false + } + if value.IsNull() { + return tags, true + } + + return tags, !value.ForEachElement(func(key, value cty.Value) bool { + // If any key is unknown or sensitive, return early as any missing tag could be this unknown key. + if !key.IsKnown() || key.IsNull() || key.IsMarked() { + return true + } + + if !value.IsKnown() || value.IsMarked() { + return true + } + + // We assume the value of the tag is ALWAYS a string + tags[key.AsString()] = value.AsString() + + return false + }) +}