diff --git a/.changelog/33339.txt b/.changelog/33339.txt new file mode 100644 index 00000000000..06287780f62 --- /dev/null +++ b/.changelog/33339.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +provider: Allow `default_tags` to be set by environment variables +``` diff --git a/internal/provider/fwprovider/provider.go b/internal/provider/fwprovider/provider.go index 907368474ad..2b3e5a70d00 100644 --- a/internal/provider/fwprovider/provider.go +++ b/internal/provider/fwprovider/provider.go @@ -263,7 +263,8 @@ func (p *fwprovider) Schema(ctx context.Context, req provider.SchemaRequest, res "tags": schema.MapAttribute{ ElementType: types.StringType, Optional: true, - Description: "Resource tags to default across all resources", + Description: "Resource tags to default across all resources. " + + "Can also be configured with environment variables like `" + tftags.DefaultTagsEnvVarPrefix + "`.", }, }, }, diff --git a/internal/provider/provider.go b/internal/provider/provider.go index d60d3f21b4d..bd1e0df26cd 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -66,10 +66,11 @@ func New(ctx context.Context) (*schema.Provider, error) { Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "tags": { - Type: schema.TypeMap, - Optional: true, - Elem: &schema.Schema{Type: schema.TypeString}, - Description: "Resource tags to default across all resources", + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Resource tags to default across all resources. " + + "Can also be configured with environment variables like `" + tftags.DefaultTagsEnvVarPrefix + "`.", }, }, }, @@ -534,6 +535,8 @@ func configure(ctx context.Context, provider *schema.Provider, d *schema.Resourc if v, ok := d.GetOk("default_tags"); ok && len(v.([]interface{})) > 0 && v.([]interface{})[0] != nil { config.DefaultTagsConfig = expandDefaultTags(ctx, v.([]interface{})[0].(map[string]interface{})) + } else { + config.DefaultTagsConfig = expandDefaultTags(ctx, nil) } v := d.Get("endpoints") @@ -839,17 +842,28 @@ func expandAssumeRoleWithWebIdentity(_ context.Context, tfMap map[string]interfa } func expandDefaultTags(ctx context.Context, tfMap map[string]interface{}) *tftags.DefaultConfig { - if tfMap == nil { - return nil + tags := make(map[string]interface{}) + for _, ev := range os.Environ() { + k, v, _ := strings.Cut(ev, "=") + before, tk, ok := strings.Cut(k, tftags.DefaultTagsEnvVarPrefix) + if ok && before == "" { + tags[tk] = v + } } - defaultConfig := &tftags.DefaultConfig{} + if cfgTags, ok := tfMap["tags"].(map[string]interface{}); ok { + for k, v := range cfgTags { + tags[k] = v + } + } - if v, ok := tfMap["tags"].(map[string]interface{}); ok { - defaultConfig.Tags = tftags.New(ctx, v) + if len(tags) > 0 { + return &tftags.DefaultConfig{ + Tags: tftags.New(ctx, tags), + } } - return defaultConfig + return nil } func expandIgnoreTags(ctx context.Context, tfMap map[string]interface{}) *tftags.IgnoreConfig { diff --git a/internal/provider/provider_acc_test.go b/internal/provider/provider_acc_test.go index 2e0aa526386..0b52d5329f6 100644 --- a/internal/provider/provider_acc_test.go +++ b/internal/provider/provider_acc_test.go @@ -24,6 +24,7 @@ import ( "github.com/hashicorp/terraform-provider-aws/internal/acctest" "github.com/hashicorp/terraform-provider-aws/internal/conns" "github.com/hashicorp/terraform-provider-aws/internal/provider" + tftags "github.com/hashicorp/terraform-provider-aws/internal/tags" "github.com/hashicorp/terraform-provider-aws/names" ) @@ -110,6 +111,32 @@ func TestAccProvider_DefaultTagsTags_multiple(t *testing.T) { }) } +func TestAccProvider_DefaultTagsTags_envVars(t *testing.T) { + ctx := acctest.Context(t) + var p *schema.Provider + + t.Setenv(tftags.DefaultTagsEnvVarPrefix+"test1", "envValue1") + t.Setenv(tftags.DefaultTagsEnvVarPrefix+"test2", "envValue2") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t), + ProtoV5ProviderFactories: testAccProtoV5ProviderFactoriesInternal(ctx, t, &p), + CheckDestroy: nil, + Steps: []resource.TestStep{ + { // nosemgrep:ci.test-config-funcs-correct-form + Config: acctest.ConfigDefaultTags_Tags1("test1", "value1"), + Check: resource.ComposeTestCheckFunc( + testAccCheckProviderDefaultTags_Tags(ctx, t, &p, map[string]string{ + "test1": "value1", + "test2": "envValue2", + }), + ), + }, + }, + }) +} + func TestAccProvider_DefaultAndIgnoreTags_emptyBlocks(t *testing.T) { ctx := acctest.Context(t) var provider *schema.Provider diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index 3b279ba06f6..b03d1503346 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -13,6 +13,7 @@ import ( "github.com/hashicorp/go-cty/cty" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-provider-aws/internal/errs/sdkdiag" + tftags "github.com/hashicorp/terraform-provider-aws/internal/tags" "github.com/hashicorp/terraform-provider-aws/names" ) @@ -217,6 +218,80 @@ func TestEndpointEnvVarPrecedence(t *testing.T) { //nolint:paralleltest } } +func TestExpandDefaultTags(t *testing.T) { //nolint:paralleltest + ctx := context.Background() + testcases := []struct { + tags map[string]interface{} + envvars map[string]string + expectedDefaultConfig *tftags.DefaultConfig + }{ + { + tags: nil, + envvars: map[string]string{}, + expectedDefaultConfig: nil, + }, + { + tags: nil, + envvars: map[string]string{ + tftags.DefaultTagsEnvVarPrefix + "Owner": "my-team", + }, + expectedDefaultConfig: &tftags.DefaultConfig{ + Tags: tftags.New(ctx, map[string]string{ + "Owner": "my-team", + }), + }, + }, + { + tags: map[string]interface{}{ + "Owner": "my-team", + }, + envvars: map[string]string{}, + expectedDefaultConfig: &tftags.DefaultConfig{ + Tags: tftags.New(ctx, map[string]string{ + "Owner": "my-team", + }), + }, + }, + { + tags: map[string]interface{}{ + "Application": "foobar", + }, + envvars: map[string]string{ + tftags.DefaultTagsEnvVarPrefix + "Application": "my-app", + tftags.DefaultTagsEnvVarPrefix + "Owner": "my-team", + }, + expectedDefaultConfig: &tftags.DefaultConfig{ + Tags: tftags.New(ctx, map[string]string{ + "Application": "foobar", + "Owner": "my-team", + }), + }, + }, + } + + for _, testcase := range testcases { + oldEnv := stashEnv() + defer popEnv(oldEnv) + for k, v := range testcase.envvars { + os.Setenv(k, v) + } + + results := expandDefaultTags(ctx, map[string]interface{}{ + "tags": testcase.tags, + }) + + if results == nil { + if testcase.expectedDefaultConfig == nil { + return + } else { + t.Errorf("Expected default tags config to be %v, got nil", testcase.expectedDefaultConfig) + } + } else if !testcase.expectedDefaultConfig.TagsEqual(results.Tags) { + t.Errorf("Expected default tags config to be %v, got %v", testcase.expectedDefaultConfig, results) + } + } +} + func stashEnv() []string { env := os.Environ() os.Clearenv() @@ -227,11 +302,7 @@ func popEnv(env []string) { os.Clearenv() for _, e := range env { - p := strings.SplitN(e, "=", 2) - k, v := p[0], "" - if len(p) > 1 { - v = p[1] - } + k, v, _ := strings.Cut(e, "=") os.Setenv(k, v) } } diff --git a/internal/tags/key_value_tags.go b/internal/tags/key_value_tags.go index 68ca0e6eb6e..5d7b92e2fe4 100644 --- a/internal/tags/key_value_tags.go +++ b/internal/tags/key_value_tags.go @@ -28,6 +28,9 @@ const ( ElasticbeanstalkTagKeyPrefix = `elasticbeanstalk:` NameTagKey = `Name` ServerlessApplicationRepositoryTagKeyPrefix = `serverlessrepo:` + + // Environment variables prefixed with this string will be treated as default_tags. + DefaultTagsEnvVarPrefix = "TF_AWS_DEFAULT_TAGS_" ) // DefaultConfig contains tags to default across all resources. diff --git a/website/docs/index.html.markdown b/website/docs/index.html.markdown index 5d5081e1dfe..11ad85f5966 100644 --- a/website/docs/index.html.markdown +++ b/website/docs/index.html.markdown @@ -651,9 +651,49 @@ vpc_resource_level_tags = tomap({ }) ``` +Example: Default tags from environment variables + +```terraform +provider "aws" { + default_tags { + tags = { + Name = "Provider Tag" + } + } +} + +resource "aws_vpc" "example" { + # ..other configuration... +} + +output "vpc_resource_level_tags" { + value = aws_vpc.example.tags +} + +output "vpc_all_tags" { + value = aws_vpc.example.tags_all +} +``` + +Outputs: + +```console +$ export TF_AWS_DEFAULT_TAGS_Environment=Test +$ terraform apply +... +Outputs: + +vpc_all_tags = tomap({ + "Environment" = "Test" + "Name" = "Provider Tag" +}) +``` + The `default_tags` configuration block supports the following argument: * `tags` - (Optional) Key-value map of tags to apply to all resources. +Default tags will also be read from environment variables matching the pattern `TF_AWS_DEFAULT_TAGS_=`. +If a tag is present in both an environment variable and this argument, the value in the provider configuration takes precedence. ### ignore_tags Configuration Block