From 80209a93a2b13025b23f4f60e9461433399ec2fe Mon Sep 17 00:00:00 2001 From: andrew quartey Date: Thu, 27 Jan 2022 13:52:21 -0500 Subject: [PATCH] Add aws_s3_bucket_cors_configuration resource (#12141) * docs: document S3 CORS configuration resource * r/s3_bucket_cors_configuration: CR updates * r/s3_bucket_cors_configuration: update ID methods to generic ones; docs formatting * correct error message Co-authored-by: Angie Pinilla --- .changelog/12141.txt | 3 + internal/provider/provider.go | 1 + .../service/s3/bucket_cors_configuration.go | 301 ++++++++++++++ .../s3/bucket_cors_configuration_test.go | 377 ++++++++++++++++++ internal/service/s3/errors.go | 1 + ...s3_bucket_cors_configuration.html.markdown | 75 ++++ 6 files changed, 758 insertions(+) create mode 100644 .changelog/12141.txt create mode 100644 internal/service/s3/bucket_cors_configuration.go create mode 100644 internal/service/s3/bucket_cors_configuration_test.go create mode 100644 website/docs/r/s3_bucket_cors_configuration.html.markdown diff --git a/.changelog/12141.txt b/.changelog/12141.txt new file mode 100644 index 000000000000..260bcc2c1f4e --- /dev/null +++ b/.changelog/12141.txt @@ -0,0 +1,3 @@ +```release-note:new-resource +aws_s3_bucket_cors_configuration +``` \ No newline at end of file diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 95781614b627..7e495efc9c0d 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -1588,6 +1588,7 @@ func Provider() *schema.Provider { "aws_s3_bucket": s3.ResourceBucket(), "aws_s3_bucket_analytics_configuration": s3.ResourceBucketAnalyticsConfiguration(), + "aws_s3_bucket_cors_configuration": s3.ResourceBucketCorsConfiguration(), "aws_s3_bucket_intelligent_tiering_configuration": s3.ResourceBucketIntelligentTieringConfiguration(), "aws_s3_bucket_inventory": s3.ResourceBucketInventory(), "aws_s3_bucket_metric": s3.ResourceBucketMetric(), diff --git a/internal/service/s3/bucket_cors_configuration.go b/internal/service/s3/bucket_cors_configuration.go new file mode 100644 index 000000000000..e22b637662fe --- /dev/null +++ b/internal/service/s3/bucket_cors_configuration.go @@ -0,0 +1,301 @@ +package s3 + +import ( + "context" + "fmt" + "log" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/hashicorp/aws-sdk-go-base/tfawserr" + "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/validation" + "github.com/hashicorp/terraform-provider-aws/internal/conns" + "github.com/hashicorp/terraform-provider-aws/internal/flex" + "github.com/hashicorp/terraform-provider-aws/internal/verify" +) + +func ResourceBucketCorsConfiguration() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceBucketCorsConfigurationCreate, + ReadContext: resourceBucketCorsConfigurationRead, + UpdateContext: resourceBucketCorsConfigurationUpdate, + DeleteContext: resourceBucketCorsConfigurationDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + "bucket": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.StringLenBetween(1, 63), + }, + "expected_bucket_owner": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ValidateFunc: verify.ValidAccountID, + }, + "cors_rule": { + Type: schema.TypeSet, + Required: true, + MaxItems: 100, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "allowed_headers": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "allowed_methods": { + Type: schema.TypeSet, + Required: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "allowed_origins": { + Type: schema.TypeSet, + Required: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "expose_headers": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "id": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.StringLenBetween(0, 255), + }, + "max_age_seconds": { + Type: schema.TypeInt, + Optional: true, + }, + }, + }, + }, + }, + } +} + +func resourceBucketCorsConfigurationCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).S3Conn + + bucket := d.Get("bucket").(string) + expectedBucketOwner := d.Get("expected_bucket_owner").(string) + + input := &s3.PutBucketCorsInput{ + Bucket: aws.String(bucket), + CORSConfiguration: &s3.CORSConfiguration{ + CORSRules: expandBucketCorsConfigurationCorsRules(d.Get("cors_rule").(*schema.Set).List()), + }, + } + + if expectedBucketOwner != "" { + input.ExpectedBucketOwner = aws.String(expectedBucketOwner) + } + + _, err := verify.RetryOnAWSCode(s3.ErrCodeNoSuchBucket, func() (interface{}, error) { + return conn.PutBucketCorsWithContext(ctx, input) + }) + + if err != nil { + return diag.FromErr(fmt.Errorf("error creating S3 bucket (%s) CORS configuration: %w", bucket, err)) + } + + d.SetId(CreateResourceID(bucket, expectedBucketOwner)) + + return resourceBucketCorsConfigurationRead(ctx, d, meta) +} + +func resourceBucketCorsConfigurationRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).S3Conn + + bucket, expectedBucketOwner, err := ParseResourceID(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + input := &s3.GetBucketCorsInput{ + Bucket: aws.String(bucket), + } + + if expectedBucketOwner != "" { + input.ExpectedBucketOwner = aws.String(expectedBucketOwner) + } + + output, err := conn.GetBucketCorsWithContext(ctx, input) + + if !d.IsNewResource() && tfawserr.ErrCodeEquals(err, s3.ErrCodeNoSuchBucket, ErrCodeNoSuchCORSConfiguration) { + log.Printf("[WARN] S3 Bucket CORS Configuration (%s) not found, removing from state", d.Id()) + d.SetId("") + return nil + } + + if err != nil { + return diag.FromErr(fmt.Errorf("error reading S3 bucket CORS configuration (%s): %w", d.Id(), err)) + } + + if output == nil { + if d.IsNewResource() { + return diag.FromErr(fmt.Errorf("error reading S3 bucket CORS configuration (%s): empty output", d.Id())) + } + log.Printf("[WARN] S3 Bucket CORS Configuration (%s) not found, removing from state", d.Id()) + d.SetId("") + return nil + } + + d.Set("bucket", bucket) + d.Set("expected_bucket_owner", expectedBucketOwner) + + if err := d.Set("cors_rule", flattenBucketCorsConfigurationCorsRules(output.CORSRules)); err != nil { + return diag.FromErr(fmt.Errorf("error setting cors_rule: %w", err)) + } + + return nil +} + +func resourceBucketCorsConfigurationUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).S3Conn + + bucket, expectedBucketOwner, err := ParseResourceID(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + input := &s3.PutBucketCorsInput{ + Bucket: aws.String(bucket), + CORSConfiguration: &s3.CORSConfiguration{ + CORSRules: expandBucketCorsConfigurationCorsRules(d.Get("cors_rule").(*schema.Set).List()), + }, + } + + if expectedBucketOwner != "" { + input.ExpectedBucketOwner = aws.String(expectedBucketOwner) + } + + _, err = conn.PutBucketCorsWithContext(ctx, input) + + if err != nil { + return diag.FromErr(fmt.Errorf("error updating S3 bucket CORS configuration (%s): %w", d.Id(), err)) + } + + return resourceBucketCorsConfigurationRead(ctx, d, meta) +} + +func resourceBucketCorsConfigurationDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).S3Conn + + bucket, expectedBucketOwner, err := ParseResourceID(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + input := &s3.DeleteBucketCorsInput{ + Bucket: aws.String(bucket), + } + + if expectedBucketOwner != "" { + input.ExpectedBucketOwner = aws.String(expectedBucketOwner) + } + + _, err = conn.DeleteBucketCorsWithContext(ctx, input) + + if tfawserr.ErrCodeEquals(err, s3.ErrCodeNoSuchBucket) { + return nil + } + + if err != nil { + return diag.FromErr(fmt.Errorf("error deleting S3 bucket CORS configuration (%s): %w", d.Id(), err)) + } + + return nil +} + +func expandBucketCorsConfigurationCorsRules(l []interface{}) []*s3.CORSRule { + if len(l) == 0 { + return nil + } + + var rules []*s3.CORSRule + + for _, tfMapRaw := range l { + tfMap, ok := tfMapRaw.(map[string]interface{}) + if !ok { + continue + } + + rule := &s3.CORSRule{} + + if v, ok := tfMap["allowed_headers"].(*schema.Set); ok && v.Len() > 0 { + rule.AllowedHeaders = flex.ExpandStringSet(v) + } + + if v, ok := tfMap["allowed_methods"].(*schema.Set); ok && v.Len() > 0 { + rule.AllowedMethods = flex.ExpandStringSet(v) + } + + if v, ok := tfMap["allowed_origins"].(*schema.Set); ok && v.Len() > 0 { + rule.AllowedOrigins = flex.ExpandStringSet(v) + } + + if v, ok := tfMap["expose_headers"].(*schema.Set); ok && v.Len() > 0 { + rule.ExposeHeaders = flex.ExpandStringSet(v) + } + + if v, ok := tfMap["id"].(string); ok && v != "" { + rule.ID = aws.String(v) + } + + if v, ok := tfMap["max_age_seconds"].(int); ok { + rule.MaxAgeSeconds = aws.Int64(int64(v)) + } + + rules = append(rules, rule) + } + + return rules +} + +func flattenBucketCorsConfigurationCorsRules(rules []*s3.CORSRule) []interface{} { + var results []interface{} + + for _, rule := range rules { + if rule == nil { + continue + } + + m := make(map[string]interface{}) + + if len(rule.AllowedHeaders) > 0 { + m["allowed_headers"] = flex.FlattenStringSet(rule.AllowedHeaders) + } + + if len(rule.AllowedMethods) > 0 { + m["allowed_methods"] = flex.FlattenStringSet(rule.AllowedMethods) + } + + if len(rule.AllowedOrigins) > 0 { + m["allowed_origins"] = flex.FlattenStringSet(rule.AllowedOrigins) + } + + if len(rule.ExposeHeaders) > 0 { + m["expose_headers"] = flex.FlattenStringSet(rule.ExposeHeaders) + } + + if rule.ID != nil { + m["id"] = aws.StringValue(rule.ID) + } + + if rule.MaxAgeSeconds != nil { + m["max_age_seconds"] = aws.Int64Value(rule.MaxAgeSeconds) + } + + results = append(results, m) + } + + return results +} diff --git a/internal/service/s3/bucket_cors_configuration_test.go b/internal/service/s3/bucket_cors_configuration_test.go new file mode 100644 index 000000000000..0f0e30adee31 --- /dev/null +++ b/internal/service/s3/bucket_cors_configuration_test.go @@ -0,0 +1,377 @@ +package s3_test + +import ( + "fmt" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/hashicorp/aws-sdk-go-base/tfawserr" + sdkacctest "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" + "github.com/hashicorp/terraform-provider-aws/internal/acctest" + "github.com/hashicorp/terraform-provider-aws/internal/conns" + tfs3 "github.com/hashicorp/terraform-provider-aws/internal/service/s3" +) + +func TestAccS3BucketCorsConfiguration_basic(t *testing.T) { + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_s3_bucket_cors_configuration.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ErrorCheck: acctest.ErrorCheck(t, s3.EndpointsID), + Providers: acctest.Providers, + CheckDestroy: testAccCheckBucketCorsConfigurationDestroy, + Steps: []resource.TestStep{ + { + Config: testAccBucketCorsConfigurationBasicConfig(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckBucketCorsConfigurationExists(resourceName), + resource.TestCheckResourceAttrPair(resourceName, "bucket", "aws_s3_bucket.test", "id"), + resource.TestCheckResourceAttr(resourceName, "cors_rule.#", "1"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "cors_rule.*", map[string]string{ + "allowed_methods.#": "1", + "allowed_origins.#": "1", + }), + resource.TestCheckTypeSetElemAttr(resourceName, "cors_rule.*.allowed_methods.*", "PUT"), + resource.TestCheckTypeSetElemAttr(resourceName, "cors_rule.*.allowed_origins.*", "https://www.example.com"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccS3BucketCorsConfiguration_disappears(t *testing.T) { + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_s3_bucket_cors_configuration.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ErrorCheck: acctest.ErrorCheck(t, s3.EndpointsID), + Providers: acctest.Providers, + CheckDestroy: testAccCheckBucketCorsConfigurationDestroy, + Steps: []resource.TestStep{ + { + Config: testAccBucketCorsConfigurationBasicConfig(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckBucketCorsConfigurationExists(resourceName), + acctest.CheckResourceDisappears(acctest.Provider, tfs3.ResourceBucketCorsConfiguration(), resourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func TestAccS3BucketCorsConfiguration_update(t *testing.T) { + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_s3_bucket_cors_configuration.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ErrorCheck: acctest.ErrorCheck(t, s3.EndpointsID), + Providers: acctest.Providers, + CheckDestroy: testAccCheckBucketCorsConfigurationDestroy, + Steps: []resource.TestStep{ + + { + Config: testAccBucketCorsConfigurationCompleteConfig_SingleRule(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckBucketCorsConfigurationExists(resourceName), + resource.TestCheckResourceAttrPair(resourceName, "bucket", "aws_s3_bucket.test", "id"), + resource.TestCheckResourceAttr(resourceName, "cors_rule.#", "1"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "cors_rule.*", map[string]string{ + "allowed_headers.#": "1", + "allowed_methods.#": "3", + "allowed_origins.#": "1", + "expose_headers.#": "1", + "id": rName, + "max_age_seconds": "3000", + }), + ), + }, + { + Config: testAccBucketCorsConfigurationConfig_MultipleRules(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckBucketCorsConfigurationExists(resourceName), + resource.TestCheckResourceAttrPair(resourceName, "bucket", "aws_s3_bucket.test", "id"), + resource.TestCheckResourceAttr(resourceName, "cors_rule.#", "2"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "cors_rule.*", map[string]string{ + "allowed_headers.#": "1", + "allowed_methods.#": "3", + "allowed_origins.#": "1", + }), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "cors_rule.*", map[string]string{ + "allowed_methods.#": "1", + "allowed_origins.#": "1", + }), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccBucketCorsConfigurationBasicConfig(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckBucketCorsConfigurationExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "cors_rule.#", "1"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "cors_rule.*", map[string]string{ + "allowed_methods.#": "1", + "allowed_origins.#": "1", + }), + ), + }, + }, + }) +} + +func TestAccS3BucketCorsConfiguration_SingleRule(t *testing.T) { + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_s3_bucket_cors_configuration.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ErrorCheck: acctest.ErrorCheck(t, s3.EndpointsID), + Providers: acctest.Providers, + CheckDestroy: testAccCheckBucketCorsConfigurationDestroy, + Steps: []resource.TestStep{ + { + Config: testAccBucketCorsConfigurationCompleteConfig_SingleRule(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckBucketCorsConfigurationExists(resourceName), + resource.TestCheckResourceAttrPair(resourceName, "bucket", "aws_s3_bucket.test", "id"), + resource.TestCheckResourceAttr(resourceName, "cors_rule.#", "1"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "cors_rule.*", map[string]string{ + "allowed_headers.#": "1", + "allowed_methods.#": "3", + "allowed_origins.#": "1", + "expose_headers.#": "1", + "id": rName, + "max_age_seconds": "3000", + }), + resource.TestCheckTypeSetElemAttr(resourceName, "cors_rule.*.allowed_headers.*", "*"), + resource.TestCheckTypeSetElemAttr(resourceName, "cors_rule.*.allowed_methods.*", "DELETE"), + resource.TestCheckTypeSetElemAttr(resourceName, "cors_rule.*.allowed_methods.*", "POST"), + resource.TestCheckTypeSetElemAttr(resourceName, "cors_rule.*.allowed_methods.*", "PUT"), + resource.TestCheckTypeSetElemAttr(resourceName, "cors_rule.*.allowed_origins.*", "https://www.example.com"), + resource.TestCheckTypeSetElemAttr(resourceName, "cors_rule.*.expose_headers.*", "ETag"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccS3BucketCorsConfiguration_MultipleRules(t *testing.T) { + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_s3_bucket_cors_configuration.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ErrorCheck: acctest.ErrorCheck(t, s3.EndpointsID), + Providers: acctest.Providers, + CheckDestroy: testAccCheckBucketCorsConfigurationDestroy, + Steps: []resource.TestStep{ + { + Config: testAccBucketCorsConfigurationConfig_MultipleRules(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckBucketCorsConfigurationExists(resourceName), + resource.TestCheckResourceAttrPair(resourceName, "bucket", "aws_s3_bucket.test", "id"), + resource.TestCheckResourceAttr(resourceName, "cors_rule.#", "2"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "cors_rule.*", map[string]string{ + "allowed_headers.#": "1", + "allowed_methods.#": "3", + "allowed_origins.#": "1", + }), + resource.TestCheckTypeSetElemAttr(resourceName, "cors_rule.*.allowed_headers.*", "*"), + resource.TestCheckTypeSetElemAttr(resourceName, "cors_rule.*.allowed_methods.*", "DELETE"), + resource.TestCheckTypeSetElemAttr(resourceName, "cors_rule.*.allowed_methods.*", "POST"), + resource.TestCheckTypeSetElemAttr(resourceName, "cors_rule.*.allowed_methods.*", "PUT"), + resource.TestCheckTypeSetElemAttr(resourceName, "cors_rule.*.allowed_origins.*", "https://www.example.com"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "cors_rule.*", map[string]string{ + "allowed_methods.#": "1", + "allowed_origins.#": "1", + }), + resource.TestCheckTypeSetElemAttr(resourceName, "cors_rule.*.allowed_methods.*", "GET"), + resource.TestCheckTypeSetElemAttr(resourceName, "cors_rule.*.allowed_origins.*", "*"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func testAccCheckBucketCorsConfigurationDestroy(s *terraform.State) error { + conn := acctest.Provider.Meta().(*conns.AWSClient).S3Conn + + for _, rs := range s.RootModule().Resources { + if rs.Type != "aws_s3_bucket_cors_configuration" { + continue + } + + bucket, expectedBucketOwner, err := tfs3.ParseResourceID(rs.Primary.ID) + if err != nil { + return err + } + + input := &s3.GetBucketCorsInput{ + Bucket: aws.String(bucket), + } + + if expectedBucketOwner != "" { + input.ExpectedBucketOwner = aws.String(expectedBucketOwner) + } + + output, err := conn.GetBucketCors(input) + + if tfawserr.ErrCodeEquals(err, s3.ErrCodeNoSuchBucket, tfs3.ErrCodeNoSuchCORSConfiguration) { + continue + } + + if err != nil { + return fmt.Errorf("error getting S3 Bucket CORS configuration (%s): %w", rs.Primary.ID, err) + } + + if output != nil { + return fmt.Errorf("S3 Bucket CORS configuration (%s) still exists", rs.Primary.ID) + } + } + + return nil +} + +func testAccCheckBucketCorsConfigurationExists(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("Resource (%s) ID not set", resourceName) + } + + conn := acctest.Provider.Meta().(*conns.AWSClient).S3Conn + + bucket, expectedBucketOwner, err := tfs3.ParseResourceID(rs.Primary.ID) + if err != nil { + return err + } + + input := &s3.GetBucketCorsInput{ + Bucket: aws.String(bucket), + } + + if expectedBucketOwner != "" { + input.ExpectedBucketOwner = aws.String(expectedBucketOwner) + } + + output, err := conn.GetBucketCors(input) + + if err != nil { + return fmt.Errorf("error getting S3 Bucket CORS configuration (%s): %w", rs.Primary.ID, err) + } + + if output == nil || len(output.CORSRules) == 0 { + return fmt.Errorf("S3 Bucket CORS configuration (%s) not found", rs.Primary.ID) + } + + return nil + } +} + +func testAccBucketCorsConfigurationBasicConfig(rName string) string { + return fmt.Sprintf(` +resource "aws_s3_bucket" "test" { + bucket = %[1]q + + lifecycle { + ignore_changes = [ + cors_rule + ] + } +} + +resource "aws_s3_bucket_cors_configuration" "test" { + bucket = aws_s3_bucket.test.id + + cors_rule { + allowed_methods = ["PUT"] + allowed_origins = ["https://www.example.com"] + } +} +`, rName) +} + +func testAccBucketCorsConfigurationCompleteConfig_SingleRule(rName string) string { + return fmt.Sprintf(` +resource "aws_s3_bucket" "test" { + bucket = %[1]q + + lifecycle { + ignore_changes = [ + cors_rule + ] + } +} + +resource "aws_s3_bucket_cors_configuration" "test" { + bucket = aws_s3_bucket.test.id + + cors_rule { + allowed_headers = ["*"] + allowed_methods = ["PUT", "POST", "DELETE"] + allowed_origins = ["https://www.example.com"] + expose_headers = ["ETag"] + id = %[1]q + max_age_seconds = 3000 + } +} +`, rName) +} + +func testAccBucketCorsConfigurationConfig_MultipleRules(rName string) string { + return fmt.Sprintf(` +resource "aws_s3_bucket" "test" { + bucket = %[1]q + + lifecycle { + ignore_changes = [ + cors_rule + ] + } +} + +resource "aws_s3_bucket_cors_configuration" "test" { + bucket = aws_s3_bucket.test.id + + cors_rule { + allowed_headers = ["*"] + allowed_methods = ["PUT", "POST", "DELETE"] + allowed_origins = ["https://www.example.com"] + } + + cors_rule { + allowed_methods = ["GET"] + allowed_origins = ["*"] + } +} +`, rName) +} diff --git a/internal/service/s3/errors.go b/internal/service/s3/errors.go index bfbbb273ba70..425080a8817d 100644 --- a/internal/service/s3/errors.go +++ b/internal/service/s3/errors.go @@ -5,6 +5,7 @@ package s3 const ( ErrCodeNoSuchConfiguration = "NoSuchConfiguration" + ErrCodeNoSuchCORSConfiguration = "NoSuchCORSConfiguration" ErrCodeNoSuchPublicAccessBlockConfiguration = "NoSuchPublicAccessBlockConfiguration" ErrCodeOperationAborted = "OperationAborted" ) diff --git a/website/docs/r/s3_bucket_cors_configuration.html.markdown b/website/docs/r/s3_bucket_cors_configuration.html.markdown new file mode 100644 index 000000000000..f747598b6238 --- /dev/null +++ b/website/docs/r/s3_bucket_cors_configuration.html.markdown @@ -0,0 +1,75 @@ +--- +subcategory: "S3" +layout: "aws" +page_title: "AWS: aws_s3_bucket_cors_configuration" +description: |- + Provides an S3 bucket CORS configuration resource. +--- + +# Resource: aws_s3_bucket_cors_configuration + +Provides an S3 bucket CORS configuration resource. For more information about CORS, go to [Enabling Cross-Origin Resource Sharing](https://docs.aws.amazon.com/AmazonS3/latest/userguide/cors.html) in the Amazon S3 User Guide. + +## Example Usage + +```terraform +resource "aws_s3_bucket" "example" { + bucket = "mybucket" +} + +resource "aws_s3_bucket_cors_configuration" "example" { + bucket = aws_s3_bucket.example.bucket + + cors_rule { + allowed_headers = ["*"] + allowed_methods = ["PUT", "POST"] + allowed_origins = ["https://s3-website-test.hashicorp.com"] + expose_headers = ["ETag"] + max_age_seconds = 3000 + } + + cors_rule { + allowed_methods = ["GET"] + allowed_origins = ["*"] + } +} +``` + +## Argument Reference + +The following arguments are supported: + +* `bucket` - (Required, Forces new resource) The name of the bucket. +* `expected_bucket_owner` - (Optional, Forces new resource) The account ID of the expected bucket owner. +* `cors_rule` - (Required) Set of origins and methods (cross-origin access that you want to allow) [documented below](#cors_rule). You can configure up to 100 rules. + +### cors_rule + +The `cors_rule` configuration block supports the following arguments: + +* `allowed_headers` - (Optional) Set of Headers that are specified in the `Access-Control-Request-Headers` header. +* `allowed_methods` - (Required) Set of HTTP methods that you allow the origin to execute. Valid values are `GET`, `PUT`, `HEAD`, `POST`, and `DELETE`. +* `allowed_origins` - (Required) Set of origins you want customers to be able to access the bucket from. +* `expose_headers` - (Optional) Set of headers in the response that you want customers to be able to access from their applications (for example, from a JavaScript `XMLHttpRequest` object). +* `id` - (Optional) Unique identifier for the rule. The value cannot be longer than 255 characters. +* `max_age_seconds` - (Optional) The time in seconds that your browser is to cache the preflight response for the specified resource. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `id` - The `bucket` or `bucket` and `expected_bucket_owner` separated by a comma (`,`) if the latter is provided. + +## Import + +S3 bucket CORS configuration can be imported using the `bucket` e.g., + +``` +$ terraform import aws_s3_bucket_cors_configuration.example bucket-name +``` + +In addition, S3 bucket CORS configuration can be imported using the `bucket` and `expected_bucket_owner` separated by a comma (`,`) e.g., + +``` +$ terraform import aws_s3_bucket_cors_configuration.example bucket-name,123456789012 +```