diff --git a/.changelog/45464.txt b/.changelog/45464.txt new file mode 100644 index 000000000000..4e7181d1b641 --- /dev/null +++ b/.changelog/45464.txt @@ -0,0 +1,9 @@ +```release-note:enhancement +provider: The [`provider_meta` block](https://developer.hashicorp.com/terraform/internals/provider-meta) is now supported. The `user_agent` argument enables module authors to include additional product information in the `User-Agent` header sent during all AWS API requests made during Create, Read, Update, and Delete operations. +``` +```release-note:enhancement +provider: Add `user_agent` argument +``` +```release-note:new-function +user_agent +``` diff --git a/internal/acctest/configs.go b/internal/acctest/configs.go index be9a373d3dd1..43be51303c16 100644 --- a/internal/acctest/configs.go +++ b/internal/acctest/configs.go @@ -318,6 +318,19 @@ provider "aws" { `, os.Getenv(envvar.AccAssumeRoleARN), policy) } +// ConfigProviderMeta returns a terraform block with provider_meta configured +func ConfigProviderMeta() string { + return ` +terraform { + provider_meta "aws" { + user_agent = [ + "test-module/0.0.1 (test comment)", + ] + } +} +` +} + const testAccProviderConfigBase = ` data "aws_region" "provider_test" {} diff --git a/internal/conns/config.go b/internal/conns/config.go index c536859083a3..257fc9491624 100644 --- a/internal/conns/config.go +++ b/internal/conns/config.go @@ -63,6 +63,7 @@ type Config struct { TokenBucketRateLimiterCapacity int UseDualStackEndpoint bool UseFIPSEndpoint bool + UserAgent awsbase.UserAgentProducts } // ConfigureProvider configures the provided provider Meta (instance data). @@ -114,6 +115,7 @@ func (c *Config) ConfigureProvider(ctx context.Context, client *AWSClient) (*AWS TokenBucketRateLimiterCapacity: c.TokenBucketRateLimiterCapacity, UseDualStackEndpoint: c.UseDualStackEndpoint, UseFIPSEndpoint: c.UseFIPSEndpoint, + UserAgent: c.UserAgent, } if c.CustomCABundle != "" { diff --git a/internal/function/user_agent.go b/internal/function/user_agent.go new file mode 100644 index 000000000000..8ed2e8ef129b --- /dev/null +++ b/internal/function/user_agent.go @@ -0,0 +1,71 @@ +// Copyright IBM Corp. 2014, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package function + +import ( + "context" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/function" +) + +var _ function.Function = userAgentFunction{} + +func NewUserAgentFunction() function.Function { + return &userAgentFunction{} +} + +type userAgentFunction struct{} + +func (f userAgentFunction) Metadata(ctx context.Context, req function.MetadataRequest, resp *function.MetadataResponse) { + resp.Name = "user_agent" +} + +func (f userAgentFunction) Definition(ctx context.Context, req function.DefinitionRequest, resp *function.DefinitionResponse) { + resp.Definition = function.Definition{ + Summary: "user_agent Function", + MarkdownDescription: "Formats a User-Agent product for use with the user_agent argument in the provider or provider_meta block.", + Parameters: []function.Parameter{ + function.StringParameter{ + Name: "product_name", + MarkdownDescription: "Product name.", + }, + function.StringParameter{ + Name: "product_version", + MarkdownDescription: "Product version.", + }, + function.StringParameter{ + Name: "comment", + MarkdownDescription: "Comment describing any additional product details.", + }, + }, + Return: function.StringReturn{}, + } +} + +func (f userAgentFunction) Run(ctx context.Context, req function.RunRequest, resp *function.RunResponse) { + var name, version, comment string + + resp.Error = function.ConcatFuncErrors(req.Arguments.Get(ctx, &name, &version, &comment)) + if resp.Error != nil { + return + } + + if name == "" { + resp.Error = function.ConcatFuncErrors(resp.Error, function.NewFuncError("product_name must be set")) + return + } + + var sb strings.Builder + + sb.WriteString(name) + if version != "" { + sb.WriteString("/" + version) + } + if comment != "" { + sb.WriteString(" (" + comment + ")") + } + + resp.Error = function.ConcatFuncErrors(resp.Result.Set(ctx, sb.String())) +} diff --git a/internal/function/user_agent_test.go b/internal/function/user_agent_test.go new file mode 100644 index 000000000000..3e9df86d392d --- /dev/null +++ b/internal/function/user_agent_test.go @@ -0,0 +1,118 @@ +// Copyright IBM Corp. 2014, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package function_test + +import ( + "fmt" + "testing" + + "github.com/YakDriver/regexache" + "github.com/hashicorp/go-version" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/tfversion" + "github.com/hashicorp/terraform-provider-aws/internal/acctest" +) + +func TestUserAgentFunction_valid(t *testing.T) { + t.Parallel() + + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(version.Must(version.NewVersion("1.8.0"))), + }, + Steps: []resource.TestStep{ + { + Config: testUserAgentFunctionConfig("test-module", "0.0.1", "test comment"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckOutput("test", "test-module/0.0.1 (test comment)"), + ), + }, + }, + }) +} + +func TestUserAgentFunction_valid_name(t *testing.T) { + t.Parallel() + + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(version.Must(version.NewVersion("1.8.0"))), + }, + Steps: []resource.TestStep{ + { + Config: testUserAgentFunctionConfig("test-module", "", ""), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckOutput("test", "test-module"), + ), + }, + }, + }) +} + +func TestUserAgentFunction_valid_nameVersion(t *testing.T) { + t.Parallel() + + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(version.Must(version.NewVersion("1.8.0"))), + }, + Steps: []resource.TestStep{ + { + Config: testUserAgentFunctionConfig("test-module", "0.0.1", ""), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckOutput("test", "test-module/0.0.1"), + ), + }, + }, + }) +} + +func TestUserAgentFunction_valid_nameComment(t *testing.T) { + t.Parallel() + + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(version.Must(version.NewVersion("1.8.0"))), + }, + Steps: []resource.TestStep{ + { + Config: testUserAgentFunctionConfig("test-module", "", "test comment"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckOutput("test", "test-module (test comment)"), + ), + }, + }, + }) +} + +func TestUserAgentFunction_invalid(t *testing.T) { + t.Parallel() + + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(version.Must(version.NewVersion("1.8.0"))), + }, + Steps: []resource.TestStep{ + { + Config: testUserAgentFunctionConfig("", "", ""), + // The full message is broken across lines, complicating validation. + // Check just the start. + ExpectError: regexache.MustCompile("product_name must be"), + }, + }, + }) +} + +func testUserAgentFunctionConfig(name, version, comment string) string { + return fmt.Sprintf(` +output "test" { + value = provider::aws::user_agent(%[1]q, %[2]q, %[3]q) +} +`, name, version, comment) +} diff --git a/internal/provider/framework/provider.go b/internal/provider/framework/provider.go index 9581e8f57d08..f77dcdb14dca 100644 --- a/internal/provider/framework/provider.go +++ b/internal/provider/framework/provider.go @@ -24,6 +24,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/function" "github.com/hashicorp/terraform-plugin-framework/list" "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/provider/metaschema" "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/resource" resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" @@ -49,6 +50,7 @@ var ( _ provider.ProviderWithFunctions = &frameworkProvider{} _ provider.ProviderWithEphemeralResources = &frameworkProvider{} _ provider.ProviderWithListResources = &frameworkProvider{} + _ provider.ProviderWithMetaSchema = &frameworkProvider{} ) type frameworkProvider struct { @@ -223,6 +225,11 @@ func (*frameworkProvider) Schema(ctx context.Context, request provider.SchemaReq Optional: true, Description: "Resolve an endpoint with FIPS capability", }, + "user_agent": schema.ListAttribute{ + ElementType: types.StringType, + Optional: true, + Description: "Product details to append to the User-Agent string sent in all AWS API calls.", + }, }, Blocks: map[string]schema.Block{ "assume_role": schema.ListNestedBlock{ @@ -351,6 +358,18 @@ func (*frameworkProvider) Schema(ctx context.Context, request provider.SchemaReq } } +func (p *frameworkProvider) MetaSchema(ctx context.Context, req provider.MetaSchemaRequest, resp *provider.MetaSchemaResponse) { + resp.Schema = metaschema.Schema{ + Attributes: map[string]metaschema.Attribute{ + "user_agent": schema.ListAttribute{ + ElementType: types.StringType, + Optional: true, + Description: "Product details to append to the User-Agent string sent in all AWS API calls.", + }, + }, + } +} + // Configure is called at the beginning of the provider lifecycle, when // Terraform sends to the provider the values the user specified in the // provider configuration block. @@ -406,6 +425,7 @@ func (p *frameworkProvider) Functions(_ context.Context) []func() function.Funct tffunction.NewARNBuildFunction, tffunction.NewARNParseFunction, tffunction.NewTrimIAMRolePathFunction, + tffunction.NewUserAgentFunction, } } diff --git a/internal/provider/framework/wrap.go b/internal/provider/framework/wrap.go index b3c20f6a4a5e..204b7ae6f36d 100644 --- a/internal/provider/framework/wrap.go +++ b/internal/provider/framework/wrap.go @@ -6,6 +6,7 @@ package framework import ( "context" + "github.com/hashicorp/aws-sdk-go-base/v2/useragent" "github.com/hashicorp/terraform-plugin-framework/action" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -13,6 +14,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/list" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/hashicorp/terraform-provider-aws/internal/conns" @@ -75,7 +77,7 @@ func newWrappedDataSource(spec *inttypes.ServicePackageFrameworkDataSource, serv } // context is run on all wrapped methods before any interceptors. -func (w *wrappedDataSource) context(ctx context.Context, getAttribute getAttributeFunc, c *conns.AWSClient) (context.Context, diag.Diagnostics) { +func (w *wrappedDataSource) context(ctx context.Context, getAttribute getAttributeFunc, providerMeta *tfsdk.Config, c *conns.AWSClient) (context.Context, diag.Diagnostics) { var diags diag.Diagnostics var overrideRegion string @@ -101,6 +103,18 @@ func (w *wrappedDataSource) context(ctx context.Context, getAttribute getAttribu ctx = fwflex.RegisterLogger(ctx) } + if providerMeta != nil { + var metadata []string + diags.Append(providerMeta.GetAttribute(ctx, path.Root("user_agent"), &metadata)...) + if diags.HasError() { + return ctx, diags + } + + if metadata != nil { + ctx = useragent.Context(ctx, useragent.FromSlice(metadata)) + } + } + return ctx, diags } @@ -110,7 +124,7 @@ func (w *wrappedDataSource) Metadata(ctx context.Context, request datasource.Met } func (w *wrappedDataSource) Schema(ctx context.Context, request datasource.SchemaRequest, response *datasource.SchemaResponse) { - ctx, diags := w.context(ctx, nil, w.meta) + ctx, diags := w.context(ctx, nil, nil, w.meta) response.Diagnostics.Append(diags...) if response.Diagnostics.HasError() { return @@ -134,7 +148,7 @@ func (w *wrappedDataSource) Schema(ctx context.Context, request datasource.Schem } func (w *wrappedDataSource) Read(ctx context.Context, request datasource.ReadRequest, response *datasource.ReadResponse) { - ctx, diags := w.context(ctx, request.Config.GetAttribute, w.meta) + ctx, diags := w.context(ctx, request.Config.GetAttribute, &request.ProviderMeta, w.meta) response.Diagnostics.Append(diags...) if response.Diagnostics.HasError() { return @@ -148,7 +162,7 @@ func (w *wrappedDataSource) Configure(ctx context.Context, request datasource.Co w.meta = v } - ctx, diags := w.context(ctx, nil, w.meta) + ctx, diags := w.context(ctx, nil, nil, w.meta) response.Diagnostics.Append(diags...) if response.Diagnostics.HasError() { return @@ -159,7 +173,7 @@ func (w *wrappedDataSource) Configure(ctx context.Context, request datasource.Co func (w *wrappedDataSource) ConfigValidators(ctx context.Context) []datasource.ConfigValidator { if v, ok := w.inner.(datasource.DataSourceWithConfigValidators); ok { - ctx, diags := w.context(ctx, nil, w.meta) + ctx, diags := w.context(ctx, nil, nil, w.meta) if diags.HasError() { tflog.Warn(ctx, "wrapping ConfigValidators", map[string]any{ "data source": w.spec.TypeName, @@ -177,7 +191,7 @@ func (w *wrappedDataSource) ConfigValidators(ctx context.Context) []datasource.C func (w *wrappedDataSource) ValidateConfig(ctx context.Context, request datasource.ValidateConfigRequest, response *datasource.ValidateConfigResponse) { if v, ok := w.inner.(datasource.DataSourceWithValidateConfig); ok { - ctx, diags := w.context(ctx, request.Config.GetAttribute, w.meta) + ctx, diags := w.context(ctx, request.Config.GetAttribute, nil, w.meta) response.Diagnostics.Append(diags...) if response.Diagnostics.HasError() { return @@ -584,7 +598,7 @@ func newWrappedResource(spec *inttypes.ServicePackageFrameworkResource, serviceP } // context is run on all wrapped methods before any interceptors. -func (w *wrappedResource) context(ctx context.Context, getAttribute getAttributeFunc, c *conns.AWSClient) (context.Context, diag.Diagnostics) { +func (w *wrappedResource) context(ctx context.Context, getAttribute getAttributeFunc, providerMeta *tfsdk.Config, c *conns.AWSClient) (context.Context, diag.Diagnostics) { var diags diag.Diagnostics var overrideRegion string @@ -610,6 +624,18 @@ func (w *wrappedResource) context(ctx context.Context, getAttribute getAttribute ctx = fwflex.RegisterLogger(ctx) } + if providerMeta != nil { + var metadata []string + diags.Append(providerMeta.GetAttribute(ctx, path.Root("user_agent"), &metadata)...) + if diags.HasError() { + return ctx, diags + } + + if metadata != nil { + ctx = useragent.Context(ctx, useragent.FromSlice(metadata)) + } + } + return ctx, diags } @@ -623,7 +649,7 @@ func (w *wrappedResource) Metadata(ctx context.Context, request resource.Metadat } func (w *wrappedResource) Schema(ctx context.Context, request resource.SchemaRequest, response *resource.SchemaResponse) { - ctx, diags := w.context(ctx, nil, w.meta) + ctx, diags := w.context(ctx, nil, nil, w.meta) response.Diagnostics.Append(diags...) if response.Diagnostics.HasError() { return @@ -644,7 +670,7 @@ func (w *wrappedResource) Schema(ctx context.Context, request resource.SchemaReq } func (w *wrappedResource) Create(ctx context.Context, request resource.CreateRequest, response *resource.CreateResponse) { - ctx, diags := w.context(ctx, request.Plan.GetAttribute, w.meta) + ctx, diags := w.context(ctx, request.Plan.GetAttribute, &request.ProviderMeta, w.meta) response.Diagnostics.Append(diags...) if response.Diagnostics.HasError() { return @@ -654,7 +680,7 @@ func (w *wrappedResource) Create(ctx context.Context, request resource.CreateReq } func (w *wrappedResource) Read(ctx context.Context, request resource.ReadRequest, response *resource.ReadResponse) { - ctx, diags := w.context(ctx, request.State.GetAttribute, w.meta) + ctx, diags := w.context(ctx, request.State.GetAttribute, &request.ProviderMeta, w.meta) response.Diagnostics.Append(diags...) if response.Diagnostics.HasError() { return @@ -664,7 +690,7 @@ func (w *wrappedResource) Read(ctx context.Context, request resource.ReadRequest } func (w *wrappedResource) Update(ctx context.Context, request resource.UpdateRequest, response *resource.UpdateResponse) { - ctx, diags := w.context(ctx, request.Plan.GetAttribute, w.meta) + ctx, diags := w.context(ctx, request.Plan.GetAttribute, &request.ProviderMeta, w.meta) response.Diagnostics.Append(diags...) if response.Diagnostics.HasError() { return @@ -674,7 +700,7 @@ func (w *wrappedResource) Update(ctx context.Context, request resource.UpdateReq } func (w *wrappedResource) Delete(ctx context.Context, request resource.DeleteRequest, response *resource.DeleteResponse) { - ctx, diags := w.context(ctx, request.State.GetAttribute, w.meta) + ctx, diags := w.context(ctx, request.State.GetAttribute, &request.ProviderMeta, w.meta) response.Diagnostics.Append(diags...) if response.Diagnostics.HasError() { return @@ -688,7 +714,7 @@ func (w *wrappedResource) Configure(ctx context.Context, request resource.Config w.meta = v } - ctx, diags := w.context(ctx, nil, w.meta) + ctx, diags := w.context(ctx, nil, nil, w.meta) response.Diagnostics.Append(diags...) if response.Diagnostics.HasError() { return @@ -699,7 +725,7 @@ func (w *wrappedResource) Configure(ctx context.Context, request resource.Config func (w *wrappedResource) ImportState(ctx context.Context, request resource.ImportStateRequest, response *resource.ImportStateResponse) { if v, ok := w.inner.(resource.ResourceWithImportState); ok { - ctx, diags := w.context(ctx, nil, w.meta) + ctx, diags := w.context(ctx, nil, nil, w.meta) response.Diagnostics.Append(diags...) if response.Diagnostics.HasError() { return @@ -718,7 +744,7 @@ func (w *wrappedResource) ImportState(ctx context.Context, request resource.Impo } func (w *wrappedResource) ModifyPlan(ctx context.Context, request resource.ModifyPlanRequest, response *resource.ModifyPlanResponse) { - ctx, diags := w.context(ctx, request.Config.GetAttribute, w.meta) + ctx, diags := w.context(ctx, request.Config.GetAttribute, &request.ProviderMeta, w.meta) response.Diagnostics.Append(diags...) if response.Diagnostics.HasError() { return @@ -735,7 +761,7 @@ func (w *wrappedResource) ModifyPlan(ctx context.Context, request resource.Modif func (w *wrappedResource) ConfigValidators(ctx context.Context) []resource.ConfigValidator { if v, ok := w.inner.(resource.ResourceWithConfigValidators); ok { - ctx, diags := w.context(ctx, nil, w.meta) + ctx, diags := w.context(ctx, nil, nil, w.meta) if diags.HasError() { tflog.Warn(ctx, "wrapping ConfigValidators", map[string]any{ "resource": w.spec.TypeName, @@ -752,7 +778,7 @@ func (w *wrappedResource) ConfigValidators(ctx context.Context) []resource.Confi } func (w *wrappedResource) ValidateConfig(ctx context.Context, request resource.ValidateConfigRequest, response *resource.ValidateConfigResponse) { - ctx, diags := w.context(ctx, request.Config.GetAttribute, w.meta) + ctx, diags := w.context(ctx, request.Config.GetAttribute, nil, w.meta) response.Diagnostics.Append(diags...) if response.Diagnostics.HasError() { return @@ -765,7 +791,7 @@ func (w *wrappedResource) ValidateConfig(ctx context.Context, request resource.V func (w *wrappedResource) UpgradeState(ctx context.Context) map[int64]resource.StateUpgrader { if v, ok := w.inner.(resource.ResourceWithUpgradeState); ok { - ctx, diags := w.context(ctx, nil, w.meta) + ctx, diags := w.context(ctx, nil, nil, w.meta) if diags.HasError() { tflog.Warn(ctx, "wrapping UpgradeState", map[string]any{ "resource": w.spec.TypeName, @@ -783,7 +809,7 @@ func (w *wrappedResource) UpgradeState(ctx context.Context) map[int64]resource.S func (w *wrappedResource) MoveState(ctx context.Context) []resource.StateMover { if v, ok := w.inner.(resource.ResourceWithMoveState); ok { - ctx, diags := w.context(ctx, nil, w.meta) + ctx, diags := w.context(ctx, nil, nil, w.meta) if diags.HasError() { tflog.Warn(ctx, "wrapping MoveState", map[string]any{ "resource": w.spec.TypeName, diff --git a/internal/provider/sdkv2/intercept.go b/internal/provider/sdkv2/intercept.go index 4205afe45cab..bf3f37221951 100644 --- a/internal/provider/sdkv2/intercept.go +++ b/internal/provider/sdkv2/intercept.go @@ -140,7 +140,7 @@ func interceptedCRUDHandler[F ~func(context.Context, *schema.ResourceData, any) return func(ctx context.Context, rd *schema.ResourceData, meta any) diag.Diagnostics { var diags diag.Diagnostics - ctx, err := bootstrapContext(ctx, rd.GetOk, meta) + ctx, err := bootstrapContext(ctx, rd.GetOk, rd.GetProviderMeta, meta) if err != nil { return sdkdiag.AppendFromErr(diags, err) } @@ -205,7 +205,7 @@ func interceptedCRUDHandler[F ~func(context.Context, *schema.ResourceData, any) func interceptedCustomizeDiffHandler(bootstrapContext contextFunc, interceptorInvocations interceptorInvocations, f schema.CustomizeDiffFunc) schema.CustomizeDiffFunc { // We run CustomizeDiff interceptors even if the resource has not defined a CustomizeDiff function. return func(ctx context.Context, d *schema.ResourceDiff, meta any) error { - ctx, err := bootstrapContext(ctx, d.GetOk, meta) + ctx, err := bootstrapContext(ctx, d.GetOk, nil, meta) if err != nil { return err } @@ -280,7 +280,7 @@ func interceptedImportHandler(bootstrapContext contextFunc, interceptorInvocatio } return func(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { - ctx, err := bootstrapContext(ctx, d.GetOk, meta) + ctx, err := bootstrapContext(ctx, d.GetOk, nil, meta) if err != nil { return nil, err } diff --git a/internal/provider/sdkv2/intercept_test.go b/internal/provider/sdkv2/intercept_test.go index 78955a1706e2..19522cb26034 100644 --- a/internal/provider/sdkv2/intercept_test.go +++ b/internal/provider/sdkv2/intercept_test.go @@ -69,7 +69,7 @@ func TestInterceptedCRUDHandler(t *testing.T) { region: "us-west-2", //lintignore:AWSAT003 } - contextFunc := func(ctx context.Context, _ getAttributeFunc, meta any) (context.Context, error) { + contextFunc := func(ctx context.Context, _ getAttributeFunc, _ getProviderMetaFunc, meta any) (context.Context, error) { return ctx, nil } @@ -374,7 +374,7 @@ func TestInterceptedCustomizeDiffHandler(t *testing.T) { region: "us-west-2", //lintignore:AWSAT003 } - contextFunc := func(ctx context.Context, _ getAttributeFunc, meta any) (context.Context, error) { + contextFunc := func(ctx context.Context, _ getAttributeFunc, _ getProviderMetaFunc, meta any) (context.Context, error) { return ctx, nil } @@ -555,7 +555,7 @@ func TestInterceptedImportHandler(t *testing.T) { region: "us-west-2", //lintignore:AWSAT003 } - contextFunc := func(ctx context.Context, _ getAttributeFunc, meta any) (context.Context, error) { + contextFunc := func(ctx context.Context, _ getAttributeFunc, _ getProviderMetaFunc, meta any) (context.Context, error) { return ctx, nil } diff --git a/internal/provider/sdkv2/provider.go b/internal/provider/sdkv2/provider.go index a411917f0b80..37d8b30afff9 100644 --- a/internal/provider/sdkv2/provider.go +++ b/internal/provider/sdkv2/provider.go @@ -19,6 +19,7 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/feature/ec2/imds" awsbase "github.com/hashicorp/aws-sdk-go-base/v2" + "github.com/hashicorp/aws-sdk-go-base/v2/useragent" "github.com/hashicorp/go-cty/cty" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" @@ -45,6 +46,11 @@ type sdkProvider struct { servicePackages iter.Seq2[int, conns.ServicePackage] } +// providerMeta matches the shape of ProviderMetaSchema +type providerMeta struct { + UserAgent []string `cty:"user_agent"` +} + // NewProvider returns a new, initialized Terraform Plugin SDK v2-style provider instance. // The provider instance is fully configured once the `ConfigureContextFunc` has been called. func NewProvider(ctx context.Context) (*schema.Provider, error) { @@ -279,6 +285,22 @@ func NewProvider(ctx context.Context) (*schema.Provider, error) { Optional: true, Description: "Resolve an endpoint with FIPS capability", }, + "user_agent": { + Type: schema.TypeList, + Optional: true, + Description: "Product details to append to the User-Agent string sent in all AWS API calls.", + Elem: &schema.Schema{Type: schema.TypeString}, + }, + }, + + // ProviderMetaSchema enables module-scoped User-Agent modifications + ProviderMetaSchema: map[string]*schema.Schema{ + "user_agent": { + Type: schema.TypeList, + Optional: true, + Description: "Product details to append to the User-Agent string sent in all AWS API calls.", + Elem: &schema.Schema{Type: schema.TypeString}, + }, }, // Data sources and resources implemented using Terraform Plugin SDK @@ -501,6 +523,10 @@ func (p *sdkProvider) configure(ctx context.Context, d *schema.ResourceData) (an } } + if v, ok := d.GetOk("user_agent"); ok && len(v.([]any)) > 0 { + config.UserAgent = useragent.FromSlice(v.([]any)) + } + var c *conns.AWSClient if v, ok := p.provider.Meta().(*conns.AWSClient); ok { c = v @@ -593,7 +619,7 @@ func (p *sdkProvider) initialize(ctx context.Context) (map[string]conns.ServiceP } opts := wrappedDataSourceOptions{ - bootstrapContext: func(ctx context.Context, getAttribute getAttributeFunc, meta any) (context.Context, error) { + bootstrapContext: func(ctx context.Context, getAttribute getAttributeFunc, getProviderMeta getProviderMetaFunc, meta any) (context.Context, error) { var overrideRegion string if isRegionOverrideEnabled && getAttribute != nil { @@ -608,6 +634,17 @@ func (p *sdkProvider) initialize(ctx context.Context) (map[string]conns.ServiceP ctx = c.RegisterLogger(ctx) } + if getProviderMeta != nil { + var metadata providerMeta + if err := getProviderMeta(&metadata); err != nil { + return ctx, fmt.Errorf("getting provider_meta: %w", err) + } + + if len(metadata.UserAgent) > 0 { + ctx = useragent.Context(ctx, useragent.FromSlice(metadata.UserAgent)) + } + } + return ctx, nil }, interceptors: interceptors, @@ -763,7 +800,7 @@ func (p *sdkProvider) initialize(ctx context.Context) (map[string]conns.ServiceP opts := wrappedResourceOptions{ // bootstrapContext is run on all wrapped methods before any interceptors. - bootstrapContext: func(ctx context.Context, getAttribute getAttributeFunc, meta any) (context.Context, error) { + bootstrapContext: func(ctx context.Context, getAttribute getAttributeFunc, getProviderMeta getProviderMetaFunc, meta any) (context.Context, error) { var overrideRegion string if isRegionOverrideEnabled && getAttribute != nil { @@ -778,6 +815,17 @@ func (p *sdkProvider) initialize(ctx context.Context) (map[string]conns.ServiceP ctx = c.RegisterLogger(ctx) } + if getProviderMeta != nil { + var metadata providerMeta + if err := getProviderMeta(&metadata); err != nil { + return ctx, fmt.Errorf("getting provider_meta: %w", err) + } + + if len(metadata.UserAgent) > 0 { + ctx = useragent.Context(ctx, useragent.FromSlice(metadata.UserAgent)) + } + } + return ctx, nil }, interceptors: interceptors, diff --git a/internal/provider/sdkv2/wrap.go b/internal/provider/sdkv2/wrap.go index 550eaaab1388..6a95421a8c6b 100644 --- a/internal/provider/sdkv2/wrap.go +++ b/internal/provider/sdkv2/wrap.go @@ -12,8 +12,11 @@ import ( // Implemented by (schema.ResourceData|schema.ResourceDiff).GetOk(). type getAttributeFunc func(string) (any, bool) +// Implemented by (schema.ResourceData).GetProviderMeta(). +type getProviderMetaFunc func(any) error + // contextFunc augments Context. -type contextFunc func(context.Context, getAttributeFunc, any) (context.Context, error) +type contextFunc func(context.Context, getAttributeFunc, getProviderMetaFunc, any) (context.Context, error) type wrappedDataSourceOptions struct { // bootstrapContext is run on all wrapped methods before any interceptors. @@ -101,7 +104,7 @@ func (w *wrappedResource) stateUpgrade(f schema.StateUpgradeFunc) schema.StateUp } return func(ctx context.Context, rawState map[string]any, meta any) (map[string]any, error) { - ctx, err := w.opts.bootstrapContext(ctx, func(key string) (any, bool) { v, ok := rawState[key]; return v, ok }, meta) + ctx, err := w.opts.bootstrapContext(ctx, func(key string) (any, bool) { v, ok := rawState[key]; return v, ok }, nil, meta) if err != nil { return nil, err } diff --git a/internal/service/iot/billing_group_test.go b/internal/service/iot/billing_group_test.go index ac9e3d265763..60b7633cbaa9 100644 --- a/internal/service/iot/billing_group_test.go +++ b/internal/service/iot/billing_group_test.go @@ -632,6 +632,37 @@ func TestAccIoTBillingGroup_requiredTags_disabled(t *testing.T) { }) } +// A smoke test to verify setting provider_meta does not trigger unexpected +// behavior for Plugin Framework based resources +func TestAccIoTBillingGroup_providerMeta(t *testing.T) { + ctx := acctest.Context(t) + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_iot_billing_group.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.IoTServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckBillingGroupDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: acctest.ConfigCompose( + acctest.ConfigProviderMeta(), + testAccBillingGroupConfig_basic(rName), + ), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckBillingGroupExists(ctx, resourceName), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + func testAccCheckBillingGroupExists(ctx context.Context, n string) resource.TestCheckFunc { return func(s *terraform.State) error { rs, ok := s.RootModule().Resources[n] diff --git a/internal/service/logs/group_test.go b/internal/service/logs/group_test.go index 3ba3c7fa7049..e3e993d40405 100644 --- a/internal/service/logs/group_test.go +++ b/internal/service/logs/group_test.go @@ -800,6 +800,38 @@ func TestAccLogsLogGroup_deletionProtectionEnabled(t *testing.T) { }) } +// A smoke test to verify setting provider_meta does not trigger unexpected +// behavior for Plugin SDK V2 based resources +func TestAccLogsLogGroup_providerMeta(t *testing.T) { + ctx := acctest.Context(t) + var v types.LogGroup + rName := acctest.RandomWithPrefix(t, acctest.ResourcePrefix) + resourceName := "aws_cloudwatch_log_group.test" + + acctest.ParallelTest(ctx, t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.LogsServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckLogGroupDestroy(ctx, t), + Steps: []resource.TestStep{ + { + Config: acctest.ConfigCompose( + acctest.ConfigProviderMeta(), + testAccGroupConfig_basic(rName), + ), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckLogGroupExists(ctx, t, resourceName, &v), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + func testAccCheckLogGroupExists(ctx context.Context, t *testing.T, n string, v *types.LogGroup) resource.TestCheckFunc { return func(s *terraform.State) error { rs, ok := s.RootModule().Resources[n] diff --git a/website/docs/functions/user_agent.html.markdown b/website/docs/functions/user_agent.html.markdown new file mode 100644 index 000000000000..7100e70d7061 --- /dev/null +++ b/website/docs/functions/user_agent.html.markdown @@ -0,0 +1,31 @@ +--- +subcategory: "" +layout: "aws" +page_title: "AWS: user_agent" +description: |- + Formats a User-Agent product for use with the `user_agent` argument in the `provider` or `provider_meta` block. +--- +# Function: user_agent + +Formats a User-Agent product for use with the `user_agent` argument in the `provider` or `provider_meta` block. + +## Example Usage + +```terraform +# result: "example-module/0.0.1 (example comment)" +output "example" { + value = provider::aws::user_agent("example-module", "0.0.1", "example comment") +} +``` + +## Signature + +```text +user_agent(product_name string, product_version string, comment string) string +``` + +## Arguments + +1. `product_name` (String) Product name. +1. `product_version` (String) Product version. +1. `comment` (String) Comment describing any additional product details. diff --git a/website/docs/index.html.markdown b/website/docs/index.html.markdown index b64456e1fc8c..019c3dfab4d8 100644 --- a/website/docs/index.html.markdown +++ b/website/docs/index.html.markdown @@ -301,12 +301,64 @@ See the assume role documentation [section on web identities](https://docs.aws.a ## Custom User-Agent Information -By default, the underlying AWS client used by the Terraform AWS Provider creates requests with User-Agent headers including information about Terraform and AWS SDK for Go versions. To provide additional information in the User-Agent headers, the `TF_APPEND_USER_AGENT` environment variable can be set and its value will be directly added to HTTP requests. E.g., +By default, the underlying AWS client used by the Terraform AWS Provider creates requests with User-Agent headers including information about Terraform and AWS SDK for Go versions. + +There are three ways to provide additional User-Agent information. + +1. The `user_agent` provider argument. +1. The `TF_APPEND_USER_AGENT` environment variable. +1. The [`provider_meta`](https://developer.hashicorp.com/terraform/internals/provider-meta) `user_agent` argument. + +-> The first two options will apply to all resources managed by the provider instance, while the `provider_meta` configuration applies only to resources in the module in which it is configured. + +### `user_agent` Provider Argument + +When using the `user_agent` provider argument, the items will be appended to the `User-Agent` header in order. +The [`user_agent` provider-defined function](./functions/user_agent.html.markdown) can be used to format the name, version, and comment components. + +```terraform +provider "aws" { + user_agent = [ + provider::aws::user_agent("example-demo", "0.0.1", "a comment"), + "other-demo/0.0.2 (other comment)", + ] +} +``` + +### `TF_APPEND_USER_AGENT` Environment Variable + +When using the environment variable, the provided value will be directly appended to the `User-Agent` header. ```console % export TF_APPEND_USER_AGENT="JenkinsAgent/i-12345678 BuildID/1234 (Optional Extra Information)" ``` +### `provider_meta` `user_agent` Argument + +The AWS provider supports sending provider metadata via the [`provider_meta` block](https://developer.hashicorp.com/terraform/internals/provider-meta). +This block allows module authors to provide additional information in the `User-Agent` header, scoped only to resources defined in a given module. + +-> In a module, `provider_meta` is defined within the `terraform` block. +The `provider` block is inherited from the root module. + +```terraform +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + } + + provider_meta "aws" { + user_agent = [ + provider::aws::user_agent("example-demo", "0.0.1", "a comment"), + "other-demo/0.0.2 (other comment)", + ] + } +} +``` + ## Argument Reference In addition to [generic `provider` arguments](https://www.terraform.io/docs/configuration/providers.html) @@ -507,6 +559,7 @@ In addition to [generic `provider` arguments](https://www.terraform.io/docs/conf This setting is ignored for any service with a custom endpoint specified. Note that not all services or regions have valid FIPS endpoints. The parameter `endpoints` can be used to override a particular service's endpoint if there is no valid FIPS endpoint. +* `user_agent` (Optional) Product details to append to the User-Agent string sent in all AWS API calls. ### assume_role Configuration Block