diff --git a/digitalocean/provider.go b/digitalocean/provider.go index b16edf641..f5feaf2e2 100644 --- a/digitalocean/provider.go +++ b/digitalocean/provider.go @@ -162,6 +162,7 @@ func Provider() *schema.Provider { "digitalocean_reserved_ip": reservedip.ResourceDigitalOceanReservedIP(), "digitalocean_reserved_ip_assignment": reservedip.ResourceDigitalOceanReservedIPAssignment(), "digitalocean_spaces_bucket": spaces.ResourceDigitalOceanBucket(), + "digitalocean_spaces_bucket_cors_configuration": spaces.ResourceDigitalOceanBucketCorsConfiguration(), "digitalocean_spaces_bucket_object": spaces.ResourceDigitalOceanSpacesBucketObject(), "digitalocean_spaces_bucket_policy": spaces.ResourceDigitalOceanSpacesBucketPolicy(), "digitalocean_ssh_key": sshkey.ResourceDigitalOceanSSHKey(), diff --git a/digitalocean/spaces/resource_spaces_bucket.go b/digitalocean/spaces/resource_spaces_bucket.go index d7bd01d7e..7f856d5ee 100644 --- a/digitalocean/spaces/resource_spaces_bucket.go +++ b/digitalocean/spaces/resource_spaces_bucket.go @@ -62,6 +62,7 @@ func ResourceDigitalOceanBucket() *schema.Resource { Type: schema.TypeList, Optional: true, Description: "A container holding a list of elements describing allowed methods for a specific origin.", + Deprecated: "Terraform will only perform drift detection if a configuration value is provided. Use the resource `digitalocean_spaces_bucket_cors_configuration` instead.", Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "allowed_methods": { diff --git a/digitalocean/spaces/resource_spaces_bucket_cors_configuration.go b/digitalocean/spaces/resource_spaces_bucket_cors_configuration.go new file mode 100644 index 000000000..90c7e63c5 --- /dev/null +++ b/digitalocean/spaces/resource_spaces_bucket_cors_configuration.go @@ -0,0 +1,287 @@ +package spaces + +import ( + "context" + "log" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/digitalocean/terraform-provider-digitalocean/digitalocean/config" + "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" +) + +func ResourceDigitalOceanBucketCorsConfiguration() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceDigitalOceanBucketCorsConfigurationCreate, + ReadContext: resourceDigitalOceanBucketCorsConfigurationRead, + UpdateContext: resourceDigitalOceanBucketCorsConfigurationUpdate, + DeleteContext: resourceBucketCorsConfigurationDelete, + Importer: &schema.ResourceImporter{ + State: resourceDigitalOceanBucketImport, + }, + + Schema: map[string]*schema.Schema{ + "bucket": { + Type: schema.TypeString, + Required: true, + Description: "Bucket ID", + }, + "region": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.StringInSlice(SpacesRegions, true), + }, + "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 resourceDigitalOceanBucketCorsConfigurationCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn, err := s3connFromSpacesBucketCorsResourceData(d, meta) + if err != nil { + return diag.Errorf("Error occurred while configuring CORS for Spaces bucket: %s", err) + } + + bucket := d.Get("bucket").(string) + + input := &s3.PutBucketCorsInput{ + Bucket: aws.String(bucket), + CORSConfiguration: &s3.CORSConfiguration{ + CORSRules: expandBucketCorsConfigurationCorsRules(d.Get("cors_rule").(*schema.Set).List()), + }, + } + + log.Printf("[DEBUG] Trying to configure CORS for Spaces bucket: %s", bucket) + _, err = conn.PutBucketCorsWithContext(ctx, input) + + if err != nil { + if awsErr, ok := err.(awserr.Error); ok && awsErr.Code() == "NoSuchKey" { + return diag.Errorf("Unable to configure CORS for Spaces bucket because the bucket does not exist: '%s'", bucket) + } + return diag.Errorf("Error occurred while configuring CORS for Spaces bucket: %s", err) + } + + d.SetId(bucket) + return resourceDigitalOceanBucketCorsConfigurationRead(ctx, d, meta) +} + +func resourceDigitalOceanBucketCorsConfigurationRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn, err := s3connFromSpacesBucketCorsResourceData(d, meta) + if err != nil { + return diag.Errorf("Error occurred while fetching Spaces bucket CORS configuration: %s", err) + } + + log.Printf("[DEBUG] Trying to fetch Spaces bucket CORS configuration for bucket: %s", d.Id()) + response, err := conn.GetBucketCorsWithContext(ctx, &s3.GetBucketCorsInput{ + Bucket: aws.String(d.Id()), + }) + + if err != nil { + return diag.Errorf("Error occurred while fetching Spaces bucket CORS configuration: %s", err) + } + + d.Set("bucket", d.Id()) + + if err := d.Set("cors_rule", flattenBucketCorsConfigurationCorsRules(response.CORSRules)); err != nil { + return diag.Errorf("setting cors_rule: %s", err) + } + + return nil +} + +func resourceDigitalOceanBucketCorsConfigurationUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + return resourceDigitalOceanBucketCorsConfigurationCreate(ctx, d, meta) +} + +func resourceBucketCorsConfigurationDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn, err := s3connFromSpacesBucketCorsResourceData(d, meta) + if err != nil { + return diag.Errorf("Error occurred while deleting Spaces bucket CORS configuration: %s", err) + } + + bucket := d.Id() + + log.Printf("[DEBUG] Trying to delete Spaces bucket CORS Configuration for bucket: %s", d.Id()) + _, err = conn.DeleteBucketCorsWithContext(ctx, &s3.DeleteBucketCorsInput{ + Bucket: aws.String(bucket), + }) + + if err != nil { + if awsErr, ok := err.(awserr.Error); ok && awsErr.Code() == "BucketDeleted" { + return diag.Errorf("Unable to remove Spaces bucket CORS configuration because bucket '%s' is already deleted", bucket) + } + return diag.Errorf("Error occurred while deleting Spaces Bucket CORS configuration: %s", err) + } + return nil + +} + +func s3connFromSpacesBucketCorsResourceData(d *schema.ResourceData, meta interface{}) (*s3.S3, error) { + region := d.Get("region").(string) + + client, err := meta.(*config.CombinedConfig).SpacesClient(region) + if err != nil { + return nil, err + } + + svc := s3.New(client) + return svc, nil +} + +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"] = flattenStringSet(rule.AllowedHeaders) + } + + if len(rule.AllowedMethods) > 0 { + m["allowed_methods"] = flattenStringSet(rule.AllowedMethods) + } + + if len(rule.AllowedOrigins) > 0 { + m["allowed_origins"] = flattenStringSet(rule.AllowedOrigins) + } + + if len(rule.ExposeHeaders) > 0 { + m["expose_headers"] = 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 +} + +func flattenStringSet(list []*string) *schema.Set { + return schema.NewSet(schema.HashString, flattenStringList(list)) // nosemgrep:ci.helper-schema-Set-extraneous-NewSet-with-FlattenStringList +} + +// flattenStringList takes list of pointers to strings. Expand to an array +// of raw strings and returns a []interface{} +// to keep compatibility w/ schema.NewSetschema.NewSet +func flattenStringList(list []*string) []interface{} { + vs := make([]interface{}, 0, len(list)) + for _, v := range list { + vs = append(vs, *v) + } + return vs +} + +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 = expandStringSet(v) + } + + if v, ok := tfMap["allowed_methods"].(*schema.Set); ok && v.Len() > 0 { + rule.AllowedMethods = expandStringSet(v) + } + + if v, ok := tfMap["allowed_origins"].(*schema.Set); ok && v.Len() > 0 { + rule.AllowedOrigins = expandStringSet(v) + } + + if v, ok := tfMap["expose_headers"].(*schema.Set); ok && v.Len() > 0 { + rule.ExposeHeaders = 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 +} + +// expandStringSet takes the result of schema.Set of strings and returns a []*string +func expandStringSet(configured *schema.Set) []*string { + return expandStringList(configured.List()) // nosemgrep:ci.helper-schema-Set-extraneous-ExpandStringList-with-List +} + +// ExpandStringList the result of flatmap.Expand for an array of strings +// and returns a []*string. Empty strings are skipped. +func expandStringList(configured []interface{}) []*string { + vs := make([]*string, 0, len(configured)) + for _, v := range configured { + if v, ok := v.(string); ok && v != "" { // v != "" may not do anything since in []interface{}, empty string will be nil so !ok + vs = append(vs, aws.String(v)) + } + } + return vs +} diff --git a/digitalocean/spaces/resource_spaces_bucket_cors_configuration_test.go b/digitalocean/spaces/resource_spaces_bucket_cors_configuration_test.go new file mode 100644 index 000000000..f4349e9da --- /dev/null +++ b/digitalocean/spaces/resource_spaces_bucket_cors_configuration_test.go @@ -0,0 +1,328 @@ +package spaces_test + +import ( + "context" + "fmt" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/digitalocean/terraform-provider-digitalocean/digitalocean/acceptance" + "github.com/digitalocean/terraform-provider-digitalocean/digitalocean/config" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +const ( + testAccDigitalOceanSpacesBucketCorsConfiguration_TestRegion = "nyc3" +) + +func TestAccDigitalOceanSpacesBucketCorsConfiguration_basic(t *testing.T) { + name := acceptance.RandomTestName() + ctx := context.Background() + region := testAccDigitalOceanSpacesBucketCorsConfiguration_TestRegion + resourceName := "digitalocean_spaces_bucket_cors_configuration.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acceptance.TestAccPreCheck(t) }, + ProviderFactories: acceptance.TestAccProviderFactories, + CheckDestroy: testAccCheckDigitalOceanSpacesBucketCorsConfigurationDestroy, + Steps: []resource.TestStep{ + { + Config: testAccSpacesBucketCORSConfigurationConfig_basic(name, region, "https://www.example.com"), + Check: resource.ComposeTestCheckFunc( + testAccCheckDigitalOceanSpacesBucketCorsConfigurationExists(ctx, resourceName), + 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"), + ), + }, + }, + }) +} + +func TestAccDigitalOceanSpacesBucketCorsConfiguration_SingleRule(t *testing.T) { + resourceName := "digitalocean_spaces_bucket_cors_configuration.test" + rName := acceptance.RandomTestName() + ctx := context.Background() + region := testAccDigitalOceanSpacesBucketCorsConfiguration_TestRegion + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acceptance.TestAccPreCheck(t) }, + ProviderFactories: acceptance.TestAccProviderFactories, + CheckDestroy: testAccCheckDigitalOceanSpacesBucketCorsConfigurationDestroy, + Steps: []resource.TestStep{ + { + Config: testAccSpacesBucketCORSConfigurationConfig_completeSingleRule(rName, region, rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckDigitalOceanSpacesBucketCorsConfigurationExists(ctx, resourceName), + resource.TestCheckResourceAttrPair(resourceName, "bucket", "digitalocean_spaces_bucket.foobar", "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"), + ), + }, + }, + }) +} + +func TestAccDigitalOceanSpacesBucketCorsConfiguration_MultipleRules(t *testing.T) { + resourceName := "digitalocean_spaces_bucket_cors_configuration.test" + rName := acceptance.RandomTestName() + ctx := context.Background() + region := testAccDigitalOceanSpacesBucketCorsConfiguration_TestRegion + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acceptance.TestAccPreCheck(t) }, + ProviderFactories: acceptance.TestAccProviderFactories, + CheckDestroy: testAccCheckDigitalOceanSpacesBucketCorsConfigurationDestroy, + Steps: []resource.TestStep{ + { + Config: testAccSpacesBucketCORSConfigurationConfig_multipleRules(rName, region, rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckDigitalOceanSpacesBucketCorsConfigurationExists(ctx, resourceName), + resource.TestCheckResourceAttrPair(resourceName, "bucket", "digitalocean_spaces_bucket.foobar", "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.*", "*"), + ), + }, + }, + }) +} + +func TestAccDigitalOceanSpacesBucketCorsConfiguration_update(t *testing.T) { + resourceName := "digitalocean_spaces_bucket_cors_configuration.test" + rName := acceptance.RandomTestName() + ctx := context.Background() + region := testAccDigitalOceanSpacesBucketCorsConfiguration_TestRegion + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acceptance.TestAccPreCheck(t) }, + ProviderFactories: acceptance.TestAccProviderFactories, + CheckDestroy: testAccCheckDigitalOceanSpacesBucketCorsConfigurationDestroy, + Steps: []resource.TestStep{ + { + Config: testAccSpacesBucketCORSConfigurationConfig_completeSingleRule(rName, region, rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckDigitalOceanSpacesBucketCorsConfigurationExists(ctx, resourceName), + resource.TestCheckResourceAttrPair(resourceName, "bucket", "digitalocean_spaces_bucket.foobar", "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: testAccSpacesBucketCORSConfigurationConfig_multipleRules(rName, region, rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckDigitalOceanSpacesBucketCorsConfigurationExists(ctx, resourceName), + resource.TestCheckResourceAttrPair(resourceName, "bucket", "digitalocean_spaces_bucket.foobar", "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", + }), + ), + }, + { + Config: testAccSpacesBucketCORSConfigurationConfig_basic(rName, region, "https://www.example.com"), + Check: resource.ComposeTestCheckFunc( + testAccCheckDigitalOceanSpacesBucketCorsConfigurationExists(ctx, resourceName), + resource.TestCheckResourceAttr(resourceName, "cors_rule.#", "1"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "cors_rule.*", map[string]string{ + "allowed_methods.#": "1", + "allowed_origins.#": "1", + }), + ), + }, + }, + }) +} + +func testAccGetS3CorsConfigurationConn() (*s3.S3, error) { + client, err := acceptance.TestAccProvider.Meta().(*config.CombinedConfig).SpacesClient(testAccDigitalOceanSpacesBucketCorsConfiguration_TestRegion) + if err != nil { + return nil, err + } + + s3conn := s3.New(client) + + return s3conn, nil +} + +func testAccCheckDigitalOceanSpacesBucketCorsConfigurationExists(ctx context.Context, 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) + } + + s3conn, err := testAccGetS3CorsConfigurationConn() + if err != nil { + return err + } + + response, err := s3conn.GetBucketCorsWithContext(context.Background(), + &s3.GetBucketCorsInput{ + Bucket: aws.String(rs.Primary.Attributes["bucket"]), + }) + if err != nil { + return fmt.Errorf("S3Bucket CORs error: %s", err) + } + + if len(response.CORSRules) == 0 { + return fmt.Errorf("S3 Bucket CORS configuration (%s) not found", rs.Primary.ID) + } + return nil + } +} + +func testAccCheckDigitalOceanSpacesBucketCorsConfigurationDestroy(s *terraform.State) error { + s3conn, err := testAccGetS3CorsConfigurationConn() + if err != nil { + return err + } + + for _, rs := range s.RootModule().Resources { + switch rs.Type { + case "digitalocean_spaces_bucket_cors_configuration": + _, err := s3conn.GetBucketCorsWithContext(context.Background(), &s3.GetBucketCorsInput{ + Bucket: aws.String(rs.Primary.Attributes["bucket"]), + }) + if err == nil { + return fmt.Errorf("Spaces Bucket Cors Configuration still exists: %s", rs.Primary.ID) + } + + case "digitalocean_spaces_bucket": + _, err = s3conn.HeadBucket(&s3.HeadBucketInput{ + Bucket: aws.String(rs.Primary.ID), + }) + if err == nil { + return fmt.Errorf("Spaces Bucket still exists: %s", rs.Primary.ID) + } + + default: + continue + } + } + + return nil +} + +func testAccSpacesBucketCORSConfigurationConfig_basic(rName string, region string, origin string) string { + return fmt.Sprintf(` +resource "digitalocean_spaces_bucket" "foobar" { + name = "%s" + region = "%s" +} + +resource "digitalocean_spaces_bucket_cors_configuration" "test" { + bucket = digitalocean_spaces_bucket.foobar.id + region = "nyc3" + + cors_rule { + allowed_methods = ["PUT"] + allowed_origins = ["%s"] + } +} +`, rName, region, origin) +} + +func testAccSpacesBucketCORSConfigurationConfig_completeSingleRule(rName string, region string, Name string) string { + return fmt.Sprintf(` +resource "digitalocean_spaces_bucket" "foobar" { + name = "%s" + region = "%s" + force_destroy = true +} + +resource "digitalocean_spaces_bucket_cors_configuration" "test" { + bucket = digitalocean_spaces_bucket.foobar.id + region = "nyc3" + + cors_rule { + allowed_headers = ["*"] + allowed_methods = ["PUT", "POST", "DELETE"] + allowed_origins = ["https://www.example.com"] + expose_headers = ["ETag"] + id = "%s" + max_age_seconds = 3000 + } +} +`, rName, region, Name) +} + +func testAccSpacesBucketCORSConfigurationConfig_multipleRules(rName string, region string, Name string) string { + return fmt.Sprintf(` +resource "digitalocean_spaces_bucket" "foobar" { + name = "%s" + region = "%s" + force_destroy = true +} + +resource "digitalocean_spaces_bucket_cors_configuration" "test" { + bucket = digitalocean_spaces_bucket.foobar.id + region = "nyc3" + + cors_rule { + allowed_headers = ["*"] + allowed_methods = ["PUT", "POST", "DELETE"] + allowed_origins = ["https://www.example.com"] + expose_headers = ["ETag"] + id = "%s" + max_age_seconds = 3000 + } + + cors_rule { + allowed_methods = ["GET"] + allowed_origins = ["*"] + } +} +`, rName, region, Name) +} diff --git a/digitalocean/spaces/resource_spaces_bucket_test.go b/digitalocean/spaces/resource_spaces_bucket_test.go index 09b0a3e63..1187942dc 100644 --- a/digitalocean/spaces/resource_spaces_bucket_test.go +++ b/digitalocean/spaces/resource_spaces_bucket_test.go @@ -111,7 +111,7 @@ func TestAccDigitalOceanBucket_UpdateCors(t *testing.T) { Check: resource.ComposeTestCheckFunc( testAccCheckDigitalOceanBucketExists("digitalocean_spaces_bucket.bucket"), resource.TestCheckNoResourceAttr( - "digitalocean_spaces_bucket.bucket", "cors_rule"), + "digitalocean_spaces_bucket.bucket", "cors_rule.#"), ), }, { diff --git a/docs/resources/spaces_bucket_cors_configuration.md b/docs/resources/spaces_bucket_cors_configuration.md new file mode 100644 index 000000000..30285a5f7 --- /dev/null +++ b/docs/resources/spaces_bucket_cors_configuration.md @@ -0,0 +1,88 @@ +--- +page_title: "DigitalOcean: digitalocean_spaces_bucket_cors_configuration" +--- + +# digitalocean\_spaces\_cors_configuration + +Provides a CORS configuration resource for Spaces, DigitalOcean's object storage product. +The `digitalocean_spaces_bucket_cors_configuration` resource allows Terraform to to attach CORS configuration to Spaces. + +The [Spaces API](https://docs.digitalocean.com/reference/api/spaces-api/) was +designed to be interoperable with Amazon's AWS S3 API. This allows users to +interact with the service while using the tools they already know. Spaces +mirrors S3's authentication framework and requests to Spaces require a key pair +similar to Amazon's Access ID and Secret Key. + +The authentication requirement can be met by either setting the +`SPACES_ACCESS_KEY_ID` and `SPACES_SECRET_ACCESS_KEY` environment variables or +the provider's `spaces_access_id` and `spaces_secret_key` arguments to the +access ID and secret you generate via the DigitalOcean control panel. For +example: + +``` +provider "digitalocean" { + token = var.digitalocean_token + + spaces_access_id = var.access_id + spaces_secret_key = var.secret_key +} + +resource "digitalocean_spaces_bucket" "static-assets" { + # ... +} +``` + +For more information, See [An Introduction to DigitalOcean Spaces](https://www.digitalocean.com/community/tutorials/an-introduction-to-digitalocean-spaces) + +## Example Usage + +### Create a Key in a Spaces Bucket + +```hcl +resource "digitalocean_spaces_bucket" "foobar" { + name = "foobar" + region = "nyc3" +} + +resource "digitalocean_spaces_bucket_cors_configuration" "test" { + bucket = digitalocean_spaces_bucket.foobar.id + region = "nyc3" + + cors_rule { + allowed_headers = ["*"] + allowed_methods = ["PUT", "POST"] + allowed_origins = ["https://s3-website-test.hashicorp.com"] + expose_headers = ["ETag"] + max_age_seconds = 3000 + } +} +``` + +## Argument Reference + +The following arguments are supported: + +* `bucket` - (Required) The name of the bucket to which to apply the CORS configuration. +* `region` - (Required) The region where the bucket resides. +* `cors_rule` - (Required) Set of origins and methods (cross-origin access that you want to allow). See below. You can configure up to 100 rules. + +`cors_rule` supports the following: + +* `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) Time in seconds that your browser is to cache the preflight response for the specified resource. + +## Attributes Reference + +No additional attributes are exported. + +## Import + +Bucket policies can be imported using the `region` and `bucket` attributes (delimited by a comma): + +``` +terraform import digitalocean_spaces_bucket_cors_configuration.foobar `region`,`bucket` +``` diff --git a/examples/spaces/main.tf b/examples/spaces/main.tf new file mode 100644 index 000000000..00f3315ec --- /dev/null +++ b/examples/spaces/main.tf @@ -0,0 +1,26 @@ +terraform { + required_providers { + digitalocean = { + source = "digitalocean/digitalocean" + version = ">= 2.4.0" + } + } +} + +resource "digitalocean_spaces_bucket" "foobar" { + name = "samiemadidriesbengals" + region = "nyc3" + } + +resource "digitalocean_spaces_bucket_cors_configuration" "foo" { + bucket = digitalocean_spaces_bucket.foobar.id + region = "nyc3" + + cors_rule { + allowed_headers = ["*"] + allowed_methods = ["PUT", "POST"] + allowed_origins = ["https://s3-website-test.hashicorp.com"] + expose_headers = ["ETag"] + max_age_seconds = 3000 + } +} \ No newline at end of file