diff --git a/aws/internal/keyvaluetags/generators/servicetags/main.go b/aws/internal/keyvaluetags/generators/servicetags/main.go index e618ab37777..f9f06946f5d 100644 --- a/aws/internal/keyvaluetags/generators/servicetags/main.go +++ b/aws/internal/keyvaluetags/generators/servicetags/main.go @@ -81,6 +81,7 @@ var sliceServiceNames = []string{ "route53", "route53resolver", "s3", + "s3control", "sagemaker", "secretsmanager", "serverlessapplicationrepository", diff --git a/aws/internal/keyvaluetags/s3control_tags.go b/aws/internal/keyvaluetags/s3control_tags.go new file mode 100644 index 00000000000..87ce384b6b1 --- /dev/null +++ b/aws/internal/keyvaluetags/s3control_tags.go @@ -0,0 +1,90 @@ +package keyvaluetags + +import ( + "fmt" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/arn" + "github.com/aws/aws-sdk-go/service/s3control" + "github.com/hashicorp/aws-sdk-go-base/tfawserr" +) + +// Custom S3control tagging functions using similar formatting as other service generated code. + +// S3controlBucketListTags lists S3control bucket tags. +// The identifier is the bucket ARN. +func S3controlBucketListTags(conn *s3control.S3Control, identifier string) (KeyValueTags, error) { + parsedArn, err := arn.Parse(identifier) + + if err != nil { + return New(nil), fmt.Errorf("error parsing S3 Control Bucket ARN (%s): %w", identifier, err) + } + + input := &s3control.GetBucketTaggingInput{ + AccountId: aws.String(parsedArn.AccountID), + Bucket: aws.String(identifier), + } + + output, err := conn.GetBucketTagging(input) + + if tfawserr.ErrCodeEquals(err, "NoSuchTagSet") { + return New(nil), nil + } + + if err != nil { + return New(nil), err + } + + return S3controlKeyValueTags(output.TagSet), nil +} + +// S3controlBucketUpdateTags updates S3control bucket tags. +// The identifier is the bucket ARN. +func S3controlBucketUpdateTags(conn *s3control.S3Control, identifier string, oldTagsMap interface{}, newTagsMap interface{}) error { + parsedArn, err := arn.Parse(identifier) + + if err != nil { + return fmt.Errorf("error parsing S3 Control Bucket ARN (%s): %w", identifier, err) + } + + oldTags := New(oldTagsMap) + newTags := New(newTagsMap) + + // We need to also consider any existing ignored tags. + allTags, err := S3controlBucketListTags(conn, identifier) + + if err != nil { + return fmt.Errorf("error listing resource tags (%s): %w", identifier, err) + } + + ignoredTags := allTags.Ignore(oldTags).Ignore(newTags) + + if len(newTags)+len(ignoredTags) > 0 { + input := &s3control.PutBucketTaggingInput{ + AccountId: aws.String(parsedArn.AccountID), + Bucket: aws.String(identifier), + Tagging: &s3control.Tagging{ + TagSet: newTags.Merge(ignoredTags).S3controlTags(), + }, + } + + _, err := conn.PutBucketTagging(input) + + if err != nil { + return fmt.Errorf("error setting resource tags (%s): %w", identifier, err) + } + } else if len(oldTags) > 0 && len(ignoredTags) == 0 { + input := &s3control.DeleteBucketTaggingInput{ + AccountId: aws.String(parsedArn.AccountID), + Bucket: aws.String(identifier), + } + + _, err := conn.DeleteBucketTagging(input) + + if err != nil { + return fmt.Errorf("error deleting resource tags (%s): %w", identifier, err) + } + } + + return nil +} diff --git a/aws/internal/keyvaluetags/service_generation_customizations.go b/aws/internal/keyvaluetags/service_generation_customizations.go index 9e44696bdd3..308cfdf557f 100644 --- a/aws/internal/keyvaluetags/service_generation_customizations.go +++ b/aws/internal/keyvaluetags/service_generation_customizations.go @@ -831,6 +831,8 @@ func ServiceTagType(serviceName string) string { return "TagListEntry" case "fms": return "ResourceTag" + case "s3control": + return "S3Tag" case "swf": return "ResourceTag" default: diff --git a/aws/internal/keyvaluetags/service_tags_gen.go b/aws/internal/keyvaluetags/service_tags_gen.go index 11613e10db7..2dacab6bbca 100644 --- a/aws/internal/keyvaluetags/service_tags_gen.go +++ b/aws/internal/keyvaluetags/service_tags_gen.go @@ -69,6 +69,7 @@ import ( "github.com/aws/aws-sdk-go/service/route53" "github.com/aws/aws-sdk-go/service/route53resolver" "github.com/aws/aws-sdk-go/service/s3" + "github.com/aws/aws-sdk-go/service/s3control" "github.com/aws/aws-sdk-go/service/sagemaker" "github.com/aws/aws-sdk-go/service/secretsmanager" "github.com/aws/aws-sdk-go/service/serverlessapplicationrepository" @@ -2322,6 +2323,33 @@ func S3KeyValueTags(tags []*s3.Tag) KeyValueTags { return New(m) } +// S3controlTags returns s3control service tags. +func (tags KeyValueTags) S3controlTags() []*s3control.S3Tag { + result := make([]*s3control.S3Tag, 0, len(tags)) + + for k, v := range tags.Map() { + tag := &s3control.S3Tag{ + Key: aws.String(k), + Value: aws.String(v), + } + + result = append(result, tag) + } + + return result +} + +// S3controlKeyValueTags creates KeyValueTags from s3control service tags. +func S3controlKeyValueTags(tags []*s3control.S3Tag) KeyValueTags { + m := make(map[string]*string, len(tags)) + + for _, tag := range tags { + m[aws.StringValue(tag.Key)] = tag.Value + } + + return New(m) +} + // SagemakerTags returns sagemaker service tags. func (tags KeyValueTags) SagemakerTags() []*sagemaker.Tag { result := make([]*sagemaker.Tag, 0, len(tags)) diff --git a/aws/provider.go b/aws/provider.go index 4040c614d91..2762f8e98ef 100644 --- a/aws/provider.go +++ b/aws/provider.go @@ -847,6 +847,7 @@ func Provider() *schema.Provider { "aws_s3_bucket_notification": resourceAwsS3BucketNotification(), "aws_s3_bucket_metric": resourceAwsS3BucketMetric(), "aws_s3_bucket_inventory": resourceAwsS3BucketInventory(), + "aws_s3control_bucket": resourceAwsS3ControlBucket(), "aws_security_group": resourceAwsSecurityGroup(), "aws_network_interface_sg_attachment": resourceAwsNetworkInterfaceSGAttachment(), "aws_default_security_group": resourceAwsDefaultSecurityGroup(), diff --git a/aws/resource_aws_s3control_bucket.go b/aws/resource_aws_s3control_bucket.go new file mode 100644 index 00000000000..cfafd0bbdb3 --- /dev/null +++ b/aws/resource_aws_s3control_bucket.go @@ -0,0 +1,196 @@ +package aws + +import ( + "fmt" + "log" + "regexp" + "strings" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/arn" + "github.com/aws/aws-sdk-go/service/s3control" + "github.com/hashicorp/aws-sdk-go-base/tfawserr" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/keyvaluetags" +) + +func resourceAwsS3ControlBucket() *schema.Resource { + return &schema.Resource{ + Create: resourceAwsS3ControlBucketCreate, + Read: resourceAwsS3ControlBucketRead, + Update: resourceAwsS3ControlBucketUpdate, + Delete: resourceAwsS3ControlBucketDelete, + + Importer: &schema.ResourceImporter{ + State: schema.ImportStatePassthrough, + }, + + Schema: map[string]*schema.Schema{ + "arn": { + Type: schema.TypeString, + Computed: true, + }, + "bucket": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.All( + validation.StringLenBetween(3, 63), + validation.StringMatch(regexp.MustCompile(`^[a-z0-9.-]+$`), "must contain only lowercase letters, numbers, periods, and hyphens"), + validation.StringMatch(regexp.MustCompile(`^[a-z0-9]`), "must begin with lowercase letter or number"), + validation.StringMatch(regexp.MustCompile(`[a-z0-9]$`), "must end with lowercase letter or number"), + ), + }, + "creation_date": { + Type: schema.TypeString, + Computed: true, + }, + "outpost_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.StringLenBetween(1, 64), + }, + "public_access_block_enabled": { + Type: schema.TypeBool, + Computed: true, + }, + "tags": tagsSchema(), + }, + } +} + +func resourceAwsS3ControlBucketCreate(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).s3controlconn + + bucket := d.Get("bucket").(string) + + input := &s3control.CreateBucketInput{ + Bucket: aws.String(bucket), + OutpostId: aws.String(d.Get("outpost_id").(string)), + } + + output, err := conn.CreateBucket(input) + + if err != nil { + return fmt.Errorf("error creating S3 Control Bucket (%s): %w", bucket, err) + } + + if output == nil { + return fmt.Errorf("error creating S3 Control Bucket (%s): empty response", bucket) + } + + d.SetId(aws.StringValue(output.BucketArn)) + + if v := d.Get("tags").(map[string]interface{}); len(v) > 0 { + if err := keyvaluetags.S3controlBucketUpdateTags(conn, d.Id(), nil, v); err != nil { + return fmt.Errorf("error adding S3 Control Bucket (%s) tags: %w", d.Id(), err) + } + } + + return resourceAwsS3ControlBucketRead(d, meta) +} + +func resourceAwsS3ControlBucketRead(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).s3controlconn + ignoreTagsConfig := meta.(*AWSClient).IgnoreTagsConfig + + parsedArn, err := arn.Parse(d.Id()) + + if err != nil { + return fmt.Errorf("error parsing S3 Control Bucket ARN (%s): %w", d.Id(), err) + } + + // ARN resource format: outpost//bucket/ + arnResourceParts := strings.Split(parsedArn.Resource, "/") + + if parsedArn.AccountID == "" || len(arnResourceParts) != 4 { + return fmt.Errorf("error parsing S3 Control Bucket ARN (%s): unknown format", d.Id()) + } + + input := &s3control.GetBucketInput{ + AccountId: aws.String(parsedArn.AccountID), + Bucket: aws.String(d.Id()), + } + + output, err := conn.GetBucket(input) + + if !d.IsNewResource() && tfawserr.ErrCodeEquals(err, "NoSuchBucket") { + log.Printf("[WARN] S3 Control Bucket (%s) not found, removing from state", d.Id()) + d.SetId("") + return nil + } + + if err != nil { + return fmt.Errorf("error reading S3 Control Bucket (%s): %w", d.Id(), err) + } + + if output == nil { + return fmt.Errorf("error reading S3 Control Bucket (%s): empty response", d.Id()) + } + + d.Set("arn", d.Id()) + d.Set("bucket", output.Bucket) + + if output.CreationDate != nil { + d.Set("creation_date", aws.TimeValue(output.CreationDate).Format(time.RFC3339)) + } + + d.Set("outpost_id", arnResourceParts[1]) + d.Set("public_access_block_enabled", output.PublicAccessBlockEnabled) + + tags, err := keyvaluetags.S3controlBucketListTags(conn, d.Id()) + + if err != nil { + return fmt.Errorf("error listing tags for S3 Control Bucket (%s): %w", d.Id(), err) + } + + if err := d.Set("tags", tags.IgnoreAws().IgnoreConfig(ignoreTagsConfig).Map()); err != nil { + return fmt.Errorf("error setting tags: %s", err) + } + + return nil +} + +func resourceAwsS3ControlBucketUpdate(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).s3controlconn + + if d.HasChange("tags") { + o, n := d.GetChange("tags") + + if err := keyvaluetags.S3controlBucketUpdateTags(conn, d.Id(), o, n); err != nil { + return fmt.Errorf("error updating S3 Control Bucket (%s) tags: %w", d.Id(), err) + } + } + + return resourceAwsS3ControlBucketRead(d, meta) +} + +func resourceAwsS3ControlBucketDelete(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).s3controlconn + + parsedArn, err := arn.Parse(d.Id()) + + if err != nil { + return fmt.Errorf("error parsing S3 Control Bucket ARN (%s): %w", d.Id(), err) + } + + input := &s3control.DeleteBucketInput{ + AccountId: aws.String(parsedArn.AccountID), + Bucket: aws.String(d.Id()), + } + + _, err = conn.DeleteBucket(input) + + if tfawserr.ErrCodeEquals(err, "NoSuchBucket") { + return nil + } + + if err != nil { + return fmt.Errorf("error deleting S3 Control Bucket (%s): %w", d.Id(), err) + } + + return nil +} diff --git a/aws/resource_aws_s3control_bucket_test.go b/aws/resource_aws_s3control_bucket_test.go new file mode 100644 index 00000000000..286fe058c88 --- /dev/null +++ b/aws/resource_aws_s3control_bucket_test.go @@ -0,0 +1,232 @@ +package aws + +import ( + "fmt" + "regexp" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/arn" + "github.com/aws/aws-sdk-go/service/s3control" + "github.com/hashicorp/aws-sdk-go-base/tfawserr" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccAWSS3ControlBucket_basic(t *testing.T) { + rName := acctest.RandomWithPrefix("tf-acc-test") + resourceName := "aws_s3control_bucket.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t); testAccPreCheckAWSOutpostsOutposts(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSS3ControlBucketDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSS3ControlBucketConfig_Bucket(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSS3ControlBucketExists(resourceName), + testAccMatchResourceAttrRegionalARN(resourceName, "arn", "s3-outposts", regexp.MustCompile(fmt.Sprintf("outpost/[^/]+/bucket/%s", rName))), + resource.TestCheckResourceAttr(resourceName, "bucket", rName), + resource.TestCheckResourceAttrSet(resourceName, "creation_date"), + resource.TestCheckResourceAttrPair(resourceName, "outpost_id", "data.aws_outposts_outpost.test", "id"), + resource.TestCheckResourceAttr(resourceName, "public_access_block_enabled", "true"), + resource.TestCheckResourceAttr(resourceName, "tags", "0"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccAWSS3ControlBucket_disappears(t *testing.T) { + rName := acctest.RandomWithPrefix("tf-acc-test") + resourceName := "aws_s3control_bucket.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t); testAccPreCheckAWSOutpostsOutposts(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSS3ControlBucketDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSS3ControlBucketConfig_Bucket(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSS3ControlBucketExists(resourceName), + testAccCheckResourceDisappears(testAccProvider, resourceAwsS3ControlBucket(), resourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func TestAccAWSS3ControlBucket_Tags(t *testing.T) { + rName := acctest.RandomWithPrefix("tf-acc-test") + resourceName := "aws_s3control_bucket.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t); testAccPreCheckAWSOutpostsOutposts(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSS3ControlBucketDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSS3ControlBucketConfig_Tags1(rName, "key1", "value1"), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSS3ControlBucketExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "tags.%", "1"), + resource.TestCheckResourceAttr(resourceName, "tags.key1", "value1"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccAWSS3ControlBucketConfig_Tags2(rName, "key1", "value1updated", "key2", "value2"), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSS3ControlBucketExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "tags.%", "2"), + resource.TestCheckResourceAttr(resourceName, "tags.key1", "value1updated"), + resource.TestCheckResourceAttr(resourceName, "tags.key2", "value2"), + ), + }, + { + Config: testAccAWSS3ControlBucketConfig_Tags1(rName, "key2", "value2"), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSS3ControlBucketExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "tags.%", "1"), + resource.TestCheckResourceAttr(resourceName, "tags.key2", "value2"), + ), + }, + }, + }) +} + +func testAccCheckAWSS3ControlBucketDestroy(s *terraform.State) error { + conn := testAccProvider.Meta().(*AWSClient).s3controlconn + + for _, rs := range s.RootModule().Resources { + if rs.Type != "aws_s3control_bucket" { + continue + } + + parsedArn, err := arn.Parse(rs.Primary.ID) + + if err != nil { + return fmt.Errorf("error parsing S3 Control Bucket ARN (%s): %w", rs.Primary.ID, err) + } + + input := &s3control.GetBucketInput{ + AccountId: aws.String(parsedArn.AccountID), + Bucket: aws.String(rs.Primary.ID), + } + + _, err = conn.GetBucket(input) + + if tfawserr.ErrCodeEquals(err, "NoSuchBucket") { + return nil + } + + if err != nil { + return err + } + + return fmt.Errorf("S3 Control Bucket (%s) still exists", rs.Primary.ID) + } + + return nil +} + +func testAccCheckAWSS3ControlBucketExists(resourceName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("not found: %s", resourceName) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("no resource ID is set") + } + + conn := testAccProvider.Meta().(*AWSClient).s3controlconn + + parsedArn, err := arn.Parse(rs.Primary.ID) + + if err != nil { + return fmt.Errorf("error parsing S3 Control Bucket ARN (%s): %w", rs.Primary.ID, err) + } + + input := &s3control.GetBucketInput{ + AccountId: aws.String(parsedArn.AccountID), + Bucket: aws.String(rs.Primary.ID), + } + + _, err = conn.GetBucket(input) + + if err != nil { + return err + } + + return nil + } +} + +func testAccAWSS3ControlBucketConfig_Bucket(rName string) string { + return fmt.Sprintf(` +data "aws_outposts_outposts" "test" {} + +data "aws_outposts_outpost" "test" { + id = tolist(data.aws_outposts_outposts.test.ids)[0] +} + +resource "aws_s3control_bucket" "test" { + bucket = %[1]q + outpost_id = data.aws_outposts_outpost.test.id +} +`, rName) +} + +func testAccAWSS3ControlBucketConfig_Tags1(rName, tagKey1, tagValue1 string) string { + return fmt.Sprintf(` +data "aws_outposts_outposts" "test" {} + +data "aws_outposts_outpost" "test" { + id = tolist(data.aws_outposts_outposts.test.ids)[0] +} + +resource "aws_s3control_bucket" "test" { + bucket = %[1]q + outpost_id = data.aws_outposts_outpost.test.id + + tags = { + %[2]q = %[3]q + } +} +`, rName, tagKey1, tagValue1) +} + +func testAccAWSS3ControlBucketConfig_Tags2(rName, tagKey1, tagValue1, tagKey2, tagValue2 string) string { + return fmt.Sprintf(` +data "aws_outposts_outposts" "test" {} + +data "aws_outposts_outpost" "test" { + id = tolist(data.aws_outposts_outposts.test.ids)[0] +} + +resource "aws_s3control_bucket" "test" { + bucket = %[1]q + outpost_id = data.aws_outposts_outpost.test.id + + tags = { + %[2]q = %[3]q + %[4]q = %[5]q + } +} +`, rName, tagKey1, tagValue1, tagKey2, tagValue2) +} diff --git a/website/docs/r/s3_bucket.html.markdown b/website/docs/r/s3_bucket.html.markdown index fd0d3e5450a..703f0111ec2 100644 --- a/website/docs/r/s3_bucket.html.markdown +++ b/website/docs/r/s3_bucket.html.markdown @@ -10,6 +10,8 @@ description: |- Provides a S3 bucket resource. +-> This functionality is for managing S3 in an AWS Partition. To manage [S3 on Outposts](https://docs.aws.amazon.com/AmazonS3/latest/dev/S3onOutposts.html), see the [`aws_s3control_bucket` resource](/docs/providers/aws/r/s3control_bucket.html). + ## Example Usage ### Private Bucket w/ Tags diff --git a/website/docs/r/s3control_bucket.html.markdown b/website/docs/r/s3control_bucket.html.markdown new file mode 100644 index 00000000000..d71da2ce72b --- /dev/null +++ b/website/docs/r/s3control_bucket.html.markdown @@ -0,0 +1,47 @@ +--- +subcategory: "S3 Control" +layout: "aws" +page_title: "AWS: aws_s3control_bucket" +description: |- + Manages an S3 Control Bucket. +--- + +# Resource: aws_s3control_bucket + +Provides a resource to manage an S3 Control Bucket. + +-> This functionality is for managing [S3 on Outposts](https://docs.aws.amazon.com/AmazonS3/latest/dev/S3onOutposts.html). To manage S3 Buckets in an AWS Partition, see the [`aws_s3_bucket` resource](/docs/providers/aws/r/s3_bucket.html). + +## Example Usage + +```hcl +resource "aws_s3control_bucket" "example" { + bucket = "example" + outpost_id = data.aws_outposts_outpost.example.id +} +``` + +## Argument Reference + +The following arguments are required: + +* `bucket` - (Required) Name of the bucket. +* `outpost_id` - (Required) Identifier of the Outpost to contain this bucket. +* `tags` - (Optional) Key-value map of resource tags. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `arn` - Amazon Resource Name (ARN) of the bucket. +* `creation_date` - UTC creation date in [RFC3339 format](https://tools.ietf.org/html/rfc3339#section-5.8). +* `id` - Amazon Resource Name (ARN) of the bucket. +* `public_access_block_enabled` - Boolean whether Public Access Block is enabled. + +## Import + +S3 Control Buckets can be imported using Amazon Resource Name (ARN), e.g. + +``` +$ terraform import aws_s3control_bucket.example arn:aws:s3-outposts:us-east-1:123456789012:outpost/op-12345678/bucket/example +``` \ No newline at end of file