diff --git a/.changelog/33262.txt b/.changelog/33262.txt new file mode 100644 index 00000000000..f634a305e57 --- /dev/null +++ b/.changelog/33262.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +resource/aws_s3_object: Add `override_provider` configuration block, allowing tags inherited from the provider [`default_tags` configuration block](https://registry.terraform.io/providers/hashicorp/aws/latest/docs#default_tags-configuration-block) to be ignored +``` \ No newline at end of file diff --git a/internal/service/s3/object.go b/internal/service/s3/object.go index 6a5c50a615e..c5674abf782 100644 --- a/internal/service/s3/object.go +++ b/internal/service/s3/object.go @@ -53,7 +53,12 @@ func ResourceObject() *schema.Resource { CustomizeDiff: customdiff.Sequence( resourceObjectCustomizeDiff, - verify.SetTagsDiff, + func(ctx context.Context, d *schema.ResourceDiff, meta interface{}) error { + if ignoreProviderDefaultTags(ctx, d) { + return d.SetNew("tags_all", d.Get("tags")) + } + return verify.SetTagsDiff(ctx, d, meta) + }, ), Schema: map[string]*schema.Schema{ @@ -180,6 +185,30 @@ func ResourceObject() *schema.Resource { Optional: true, ValidateFunc: validation.IsRFC3339Time, }, + "override_provider": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "default_tags": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "tags": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + ValidateDiagFunc: verify.MapLenBetween(0, 0), + }, + }, + }, + }, + }, + }, + }, "server_side_encryption": { Type: schema.TypeString, Optional: true, @@ -403,7 +432,13 @@ func resourceObjectUpload(ctx context.Context, d *schema.ResourceData, meta inte conn := meta.(*conns.AWSClient).S3Client(ctx) uploader := manager.NewUploader(conn) defaultTagsConfig := meta.(*conns.AWSClient).DefaultTagsConfig - tags := defaultTagsConfig.MergeTags(tftags.New(ctx, d.Get("tags").(map[string]interface{}))) + tags := tftags.New(ctx, d.Get("tags").(map[string]interface{})) + + if ignoreProviderDefaultTags(ctx, d) { + tags = tags.RemoveDefaultConfig(defaultTagsConfig) + } else { + tags = defaultTagsConfig.MergeTags(tftags.New(ctx, tags)) + } var body io.ReadSeeker @@ -669,3 +704,50 @@ func sdkv1CompatibleCleanKey(key string) string { key = regexache.MustCompile(`/+`).ReplaceAllString(key, "/") return key } + +func ignoreProviderDefaultTags(ctx context.Context, d verify.ResourceDiffer) bool { + if v, ok := d.GetOk("override_provider"); ok && len(v.([]interface{})) > 0 && v.([]interface{})[0] != nil { + if data := expandOverrideProviderModel(ctx, v.([]interface{})[0].(map[string]interface{})); data != nil && data.DefaultTagsConfig != nil { + return len(data.DefaultTagsConfig.Tags) == 0 + } + } + + return false +} + +type overrideProviderModel struct { + DefaultTagsConfig *tftags.DefaultConfig +} + +func expandOverrideProviderModel(ctx context.Context, tfMap map[string]interface{}) *overrideProviderModel { + if tfMap == nil { + return nil + } + + data := &overrideProviderModel{} + + if v, ok := tfMap["default_tags"].([]interface{}); ok && len(v) > 0 { + if v[0] != nil { + data.DefaultTagsConfig = expandDefaultTags(ctx, v[0].(map[string]interface{})) + } else { + // Ensure that DefaultTagsConfig is not nil as it's checked in ignoreProviderDefaultTags. + data.DefaultTagsConfig = expandDefaultTags(ctx, map[string]interface{}{}) + } + } + + return data +} + +func expandDefaultTags(ctx context.Context, tfMap map[string]interface{}) *tftags.DefaultConfig { + if tfMap == nil { + return nil + } + + data := &tftags.DefaultConfig{} + + if v, ok := tfMap["tags"].(map[string]interface{}); ok { + data.Tags = tftags.New(ctx, v) + } + + return data +} diff --git a/internal/service/s3/object_test.go b/internal/service/s3/object_test.go index 7d743a679b4..806b7dd076b 100644 --- a/internal/service/s3/object_test.go +++ b/internal/service/s3/object_test.go @@ -848,9 +848,9 @@ func TestAccS3Object_storageClass(t *testing.T) { func TestAccS3Object_tags(t *testing.T) { ctx := acctest.Context(t) var obj1, obj2, obj3, obj4 s3.GetObjectOutput - rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) resourceName := "aws_s3_object.object" key := "test-key" + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) resource.ParallelTest(t, resource.TestCase{ PreCheck: func() { acctest.PreCheck(ctx, t) }, @@ -1107,6 +1107,149 @@ func TestAccS3Object_tagsMultipleSlashes(t *testing.T) { }) } +func TestAccS3Object_DefaultTags_providerOnly(t *testing.T) { + ctx := acctest.Context(t) + var obj s3.GetObjectOutput + resourceName := "aws_s3_object.object" + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.S3EndpointID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckObjectDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: acctest.ConfigCompose( + acctest.ConfigDefaultTags_Tags1("providerkey1", "providervalue1"), + testAccObjectConfig_basic(rName), + ), + Check: resource.ComposeTestCheckFunc( + testAccCheckObjectExists(ctx, resourceName, &obj), + resource.TestCheckResourceAttr(resourceName, "tags.%", "0"), + resource.TestCheckResourceAttr(resourceName, "tags_all.%", "1"), + resource.TestCheckResourceAttr(resourceName, "tags_all.providerkey1", "providervalue1"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"force_destroy"}, + ImportStateId: fmt.Sprintf("s3://%s/test-key", rName), + }, + }, + }) +} + +func TestAccS3Object_DefaultTags_providerAndResource(t *testing.T) { + ctx := acctest.Context(t) + var obj s3.GetObjectOutput + resourceName := "aws_s3_object.object" + key := "test-key" + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.S3EndpointID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckObjectDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: acctest.ConfigCompose( + acctest.ConfigDefaultTags_Tags1("providerkey1", "providervalue1"), + testAccObjectConfig_tags(rName, key, "stuff"), + ), + Check: resource.ComposeTestCheckFunc( + testAccCheckObjectExists(ctx, resourceName, &obj), + resource.TestCheckResourceAttr(resourceName, "tags.%", "3"), + resource.TestCheckResourceAttr(resourceName, "tags.Key1", "A@AA"), + resource.TestCheckResourceAttr(resourceName, "tags.Key2", "BBB"), + resource.TestCheckResourceAttr(resourceName, "tags.Key3", "CCC"), + resource.TestCheckResourceAttr(resourceName, "tags_all.%", "4"), + resource.TestCheckResourceAttr(resourceName, "tags_all.providerkey1", "providervalue1"), + resource.TestCheckResourceAttr(resourceName, "tags_all.Key1", "A@AA"), + resource.TestCheckResourceAttr(resourceName, "tags_all.Key2", "BBB"), + resource.TestCheckResourceAttr(resourceName, "tags_all.Key3", "CCC"), + ), + }, + { + Config: acctest.ConfigCompose( + acctest.ConfigDefaultTags_Tags1("providerkey1", "providervalue1"), + testAccObjectConfig_updatedTags(rName, key, "stuff"), + ), + Check: resource.ComposeTestCheckFunc( + testAccCheckObjectExists(ctx, resourceName, &obj), + resource.TestCheckResourceAttr(resourceName, "tags.%", "4"), + resource.TestCheckResourceAttr(resourceName, "tags.Key2", "B@BB"), + resource.TestCheckResourceAttr(resourceName, "tags.Key3", "X X"), + resource.TestCheckResourceAttr(resourceName, "tags.Key4", "DDD"), + resource.TestCheckResourceAttr(resourceName, "tags.Key5", "E:/"), + resource.TestCheckResourceAttr(resourceName, "tags_all.%", "5"), + resource.TestCheckResourceAttr(resourceName, "tags_all.providerkey1", "providervalue1"), + resource.TestCheckResourceAttr(resourceName, "tags_all.Key2", "B@BB"), + resource.TestCheckResourceAttr(resourceName, "tags_all.Key3", "X X"), + resource.TestCheckResourceAttr(resourceName, "tags_all.Key4", "DDD"), + resource.TestCheckResourceAttr(resourceName, "tags_all.Key5", "E:/"), + ), + }, + }, + }) +} + +func TestAccS3Object_DefaultTags_providerAndResourceWithOverride(t *testing.T) { + ctx := acctest.Context(t) + var obj s3.GetObjectOutput + resourceName := "aws_s3_object.object" + key := "test-key" + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.S3EndpointID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckObjectDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: acctest.ConfigCompose( + acctest.ConfigDefaultTags_Tags1("providerkey1", "providervalue1"), + testAccObjectConfig_tagsWithOverride(rName, key, "stuff"), + ), + Check: resource.ComposeTestCheckFunc( + testAccCheckObjectExists(ctx, resourceName, &obj), + resource.TestCheckResourceAttr(resourceName, "tags.%", "3"), + resource.TestCheckResourceAttr(resourceName, "tags.Key1", "A@AA"), + resource.TestCheckResourceAttr(resourceName, "tags.Key2", "BBB"), + resource.TestCheckResourceAttr(resourceName, "tags.Key3", "CCC"), + resource.TestCheckResourceAttr(resourceName, "tags_all.%", "3"), + resource.TestCheckResourceAttr(resourceName, "tags_all.Key1", "A@AA"), + resource.TestCheckResourceAttr(resourceName, "tags_all.Key2", "BBB"), + resource.TestCheckResourceAttr(resourceName, "tags_all.Key3", "CCC"), + ), + }, + { + Config: acctest.ConfigCompose( + acctest.ConfigDefaultTags_Tags1("providerkey1", "providervalue1"), + testAccObjectConfig_updatedTagsWithOverride(rName, key, "stuff"), + ), + Check: resource.ComposeTestCheckFunc( + testAccCheckObjectExists(ctx, resourceName, &obj), + resource.TestCheckResourceAttr(resourceName, "tags.%", "4"), + resource.TestCheckResourceAttr(resourceName, "tags.Key2", "B@BB"), + resource.TestCheckResourceAttr(resourceName, "tags.Key3", "X X"), + resource.TestCheckResourceAttr(resourceName, "tags.Key4", "DDD"), + resource.TestCheckResourceAttr(resourceName, "tags.Key5", "E:/"), + resource.TestCheckResourceAttr(resourceName, "tags_all.%", "4"), + resource.TestCheckResourceAttr(resourceName, "tags_all.Key2", "B@BB"), + resource.TestCheckResourceAttr(resourceName, "tags_all.Key3", "X X"), + resource.TestCheckResourceAttr(resourceName, "tags_all.Key4", "DDD"), + resource.TestCheckResourceAttr(resourceName, "tags_all.Key5", "E:/"), + ), + }, + }, + }) +} + func TestAccS3Object_objectLockLegalHoldStartWithNone(t *testing.T) { ctx := acctest.Context(t) var obj1, obj2, obj3 s3.GetObjectOutput @@ -2039,6 +2182,75 @@ resource "aws_s3_object" "object" { `, rName, key, content) } +func testAccObjectConfig_tagsWithOverride(rName, key, content string) string { + return fmt.Sprintf(` +resource "aws_s3_bucket" "test" { + bucket = %[1]q +} + +resource "aws_s3_bucket_versioning" "test" { + bucket = aws_s3_bucket.test.id + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_object" "object" { + # Must have bucket versioning enabled first + bucket = aws_s3_bucket_versioning.test.bucket + key = %[2]q + content = %[3]q + + tags = { + Key1 = "A@AA" + Key2 = "BBB" + Key3 = "CCC" + } + + override_provider { + default_tags { + tags = {} + } + } +} +`, rName, key, content) +} + +func testAccObjectConfig_updatedTagsWithOverride(rName, key, content string) string { + return fmt.Sprintf(` +resource "aws_s3_bucket" "test" { + bucket = %[1]q +} + +resource "aws_s3_bucket_versioning" "test" { + bucket = aws_s3_bucket.test.id + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_object" "object" { + # Must have bucket versioning enabled first + bucket = aws_s3_bucket_versioning.test.bucket + key = %[2]q + content = %[3]q + + tags = { + Key2 = "B@BB" + Key3 = "X X" + Key4 = "DDD" + Key5 = "E:/" + } + + override_provider { + default_tags { + tags = {} + } + } +} +`, rName, key, content) +} + func testAccObjectConfig_metadata(rName string, metadataKey1, metadataValue1, metadataKey2, metadataValue2 string) string { return fmt.Sprintf(` resource "aws_s3_bucket" "test" { @@ -2210,7 +2422,7 @@ resource "aws_s3_bucket" "test" { resource "aws_s3_object" "object" { bucket = aws_s3_bucket.test.bucket key = "test-key" - content = %q + content = %[2]q kms_key_id = aws_kms_key.test.arn bucket_key_enabled = true } @@ -2246,7 +2458,7 @@ resource "aws_s3_object" "object" { bucket = aws_s3_bucket.test.bucket key = "test-key" - content = %q + content = %[2]q } `, rName, content) } diff --git a/internal/verify/resource_differ.go b/internal/verify/resource_differ.go index 20977039f0b..b2d1c6fa9d4 100644 --- a/internal/verify/resource_differ.go +++ b/internal/verify/resource_differ.go @@ -9,5 +9,6 @@ package verify // * schema.ResourceDiff // FIXME: can be removed if https://github.com/hashicorp/terraform-plugin-sdk/pull/626/files is merged type ResourceDiffer interface { + GetOk(string) (interface{}, bool) HasChange(string) bool } diff --git a/internal/verify/validate.go b/internal/verify/validate.go index f8408784fcd..e9329100273 100644 --- a/internal/verify/validate.go +++ b/internal/verify/validate.go @@ -14,6 +14,8 @@ import ( "github.com/YakDriver/regexache" "github.com/aws/aws-sdk-go/aws/arn" basevalidation "github.com/hashicorp/aws-sdk-go-base/v2/validation" + "github.com/hashicorp/go-cty/cty" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/structure" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" @@ -475,3 +477,21 @@ func ValidServicePrincipal(v interface{}, k string) (ws []string, errors []error func IsServicePrincipal(value string) (valid bool) { return servicePrincipalRegexp.MatchString(value) } + +func MapLenBetween(min, max int) schema.SchemaValidateDiagFunc { + return func(v interface{}, path cty.Path) diag.Diagnostics { + var diags diag.Diagnostics + m := v.(map[string]interface{}) + + if l := len(m); l < min || l > max { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Bad map length", + Detail: fmt.Sprintf("Map must contain at least %d elements and at most %d elements: length=%d", min, max, l), + AttributePath: path, + }) + } + + return diags + } +} diff --git a/internal/verify/validate_test.go b/internal/verify/validate_test.go index 198d2ca076d..60d5231cb42 100644 --- a/internal/verify/validate_test.go +++ b/internal/verify/validate_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/YakDriver/regexache" + "github.com/hashicorp/go-cty/cty" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" ) @@ -780,3 +781,51 @@ func TestValidServicePrincipal(t *testing.T) { } } } + +func TestMapLenBetween(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + value interface{} + wantErr bool + }{ + { + name: "too long", + value: map[string]interface{}{ + "K1": "V1", + "K2": "V2", + "K3": "V3", + "K4": "V4", + "K5": "V5", + }, + wantErr: true, + }, + { + name: "too short", + value: map[string]interface{}{ + "K1": "V1", + }, + wantErr: true, + }, + { + name: "ok", + value: map[string]interface{}{ + "K1": "V1", + "K2": "V2", + }, + }, + } + f := MapLenBetween(2, 4) + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + diags := f(testCase.value, cty.Path{}) + if got, want := diags.HasError(), testCase.wantErr; got != want { + t.Errorf("got = %v, want = %v", got, want) + } + }) + } +} diff --git a/website/docs/r/s3_object.html.markdown b/website/docs/r/s3_object.html.markdown index 9bb45d8f794..58b8d6b4979 100644 --- a/website/docs/r/s3_object.html.markdown +++ b/website/docs/r/s3_object.html.markdown @@ -129,6 +129,33 @@ resource "aws_s3_object" "examplebucket_object" { } ``` +### Ignoring Provider `default_tags` + +S3 objects support a [maximum of 10 tags](https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-tagging.html). +If the resource's own `tags` and the provider-level `default_tags` would together lead to more than 10 tags on an S3 object, use the `override_provider` configuration block to suppress any provider-level `default_tags`. + +```terraform +resource "aws_s3_bucket" "examplebucket" { + bucket = "examplebuckettftest" +} + +resource "aws_s3_object" "examplebucket_object" { + key = "someobject" + bucket = aws_s3_bucket.examplebucket.id + source = "important.txt" + + tags = { + Env = "test" + } + + override_provider { + default_tags { + tags = {} + } + } +} +``` + ## Argument Reference -> **Note:** If you specify `content_encoding` you are responsible for encoding the body appropriately. `source`, `content`, and `content_base64` all expect already encoded/compressed bytes. @@ -157,6 +184,7 @@ The following arguments are optional: * `object_lock_legal_hold_status` - (Optional) [Legal hold](https://docs.aws.amazon.com/AmazonS3/latest/dev/object-lock-overview.html#object-lock-legal-holds) status that you want to apply to the specified object. Valid values are `ON` and `OFF`. * `object_lock_mode` - (Optional) Object lock [retention mode](https://docs.aws.amazon.com/AmazonS3/latest/dev/object-lock-overview.html#object-lock-retention-modes) that you want to apply to this object. Valid values are `GOVERNANCE` and `COMPLIANCE`. * `object_lock_retain_until_date` - (Optional) Date and time, in [RFC3339 format](https://tools.ietf.org/html/rfc3339#section-5.8), when this object's object lock will [expire](https://docs.aws.amazon.com/AmazonS3/latest/dev/object-lock-overview.html#object-lock-retention-periods). +* `override_provider` - (Optional) Override provider-level configuration options. See [Override Provider](#override-provider) below for more details. * `server_side_encryption` - (Optional) Server-side encryption of the object in S3. Valid values are "`AES256`" and "`aws:kms`". * `source_hash` - (Optional) Triggers updates like `etag` but useful to address `etag` encryption limitations. Set using `filemd5("path/to/source")` (Terraform 0.11.12 or later). (The value is only stored in state and not saved by AWS.) * `source` - (Optional, conflicts with `content` and `content_base64`) Path to a file that will be read and uploaded as raw bytes for the object content. @@ -168,6 +196,12 @@ If no content is provided through `source`, `content` or `content_base64`, then -> **Note:** Terraform ignores all leading `/`s in the object's `key` and treats multiple `/`s in the rest of the object's `key` as a single `/`, so values of `/index.html` and `index.html` correspond to the same S3 object as do `first//second///third//` and `first/second/third/`. +### Override Provider + +The `override_provider` block supports the following: + +* `default_tags` - (Optional) Override the provider [`default_tags` configuration block](https://registry.terraform.io/providers/hashicorp/aws/latest/docs#default_tags-configuration-block). + ## Attribute Reference This resource exports the following attributes in addition to the arguments above: