From bf1161fbbe6b68f143273727b19d86ad91ecdc50 Mon Sep 17 00:00:00 2001 From: Assem Date: Tue, 29 Aug 2023 19:19:36 +0200 Subject: [PATCH 1/7] fix(docs): make it clear that volume has to start with a letter (#1024) --- docs/resources/volume.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/resources/volume.md b/docs/resources/volume.md index ec1746ba3..b18ac512f 100644 --- a/docs/resources/volume.md +++ b/docs/resources/volume.md @@ -50,7 +50,7 @@ resource "digitalocean_volume" "foobar" { The following arguments are supported: * `region` - (Required) The region that the block storage volume will be created in. -* `name` - (Required) A name for the block storage volume. Must be lowercase and be composed only of numbers, letters and "-", up to a limit of 64 characters. +* `name` - (Required) A name for the block storage volume. Must be lowercase and be composed only of numbers, letters and "-", up to a limit of 64 characters. The name must begin with a letter. * `size` - (Required) The size of the block storage volume in GiB. If updated, can only be expanded. * `description` - (Optional) A free-form text field up to a limit of 1024 bytes to describe a block storage volume. * `snapshot_id` - (Optional) The ID of an existing volume snapshot from which the new volume will be created. If supplied, the region and size will be limitied on creation to that of the referenced snapshot From f5bd1a199cb07d516d340425ffa750e72c255be5 Mon Sep 17 00:00:00 2001 From: danaelhe <42972711+danaelhe@users.noreply.github.com> Date: Tue, 29 Aug 2023 14:35:38 -0400 Subject: [PATCH 2/7] Spaces: Add CORS Configuration Support (#1021) * Spaces: Add CORS Coniguration Support * terrafmt fmt --fmtcompat digitalocean/ * test fixes + deprecation * typo * update test * fix spaces_bucket_test test too --- digitalocean/provider.go | 1 + digitalocean/spaces/resource_spaces_bucket.go | 1 + ...source_spaces_bucket_cors_configuration.go | 287 +++++++++++++++ ...e_spaces_bucket_cors_configuration_test.go | 328 ++++++++++++++++++ .../spaces/resource_spaces_bucket_test.go | 2 +- .../spaces_bucket_cors_configuration.md | 88 +++++ examples/spaces/main.tf | 26 ++ 7 files changed, 732 insertions(+), 1 deletion(-) create mode 100644 digitalocean/spaces/resource_spaces_bucket_cors_configuration.go create mode 100644 digitalocean/spaces/resource_spaces_bucket_cors_configuration_test.go create mode 100644 docs/resources/spaces_bucket_cors_configuration.md create mode 100644 examples/spaces/main.tf 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 From 775a497b5542e0aa199a6e27177255bfa0825281 Mon Sep 17 00:00:00 2001 From: Andrew Starr-Bochicchio Date: Tue, 29 Aug 2023 17:21:02 -0400 Subject: [PATCH 3/7] Bump Go version to v1.21.0 (#1025) --- .github/workflows/acceptance-test-pr.yml | 2 +- .github/workflows/acceptance-test-schedule.yml | 4 ++-- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 2 +- go.mod | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/acceptance-test-pr.yml b/.github/workflows/acceptance-test-pr.yml index da2be2d93..7e8249900 100644 --- a/.github/workflows/acceptance-test-pr.yml +++ b/.github/workflows/acceptance-test-pr.yml @@ -31,7 +31,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v2 with: - go-version: 1.20.x + go-version: 1.21.x - name: Checkout PR uses: actions/checkout@v2 diff --git a/.github/workflows/acceptance-test-schedule.yml b/.github/workflows/acceptance-test-schedule.yml index 1d775b068..bc11ed718 100644 --- a/.github/workflows/acceptance-test-schedule.yml +++ b/.github/workflows/acceptance-test-schedule.yml @@ -16,7 +16,7 @@ jobs: - name: Install Go uses: actions/setup-go@v2 with: - go-version: 1.20.x + go-version: 1.21.x - name: Checkout uses: actions/checkout@v2 @@ -38,7 +38,7 @@ jobs: - name: Install Go uses: actions/setup-go@v2 with: - go-version: 1.20.x + go-version: 1.21.x - name: Checkout uses: actions/checkout@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa3ab96fb..28ac8aaf5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v2 with: - go-version: 1.20.x + go-version: 1.21.x - name: Import GPG key id: import_gpg diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4b9f89a65..0a4ff0ffc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,7 @@ jobs: # Runs `go vet` and unit tests. strategy: matrix: - go-version: [1.19.x, 1.20.x] + go-version: [1.20.x, 1.21.x] runs-on: ubuntu-latest steps: diff --git a/go.mod b/go.mod index 79ddb4784..226ef0222 100644 --- a/go.mod +++ b/go.mod @@ -71,4 +71,4 @@ replace git.apache.org/thrift.git => github.com/apache/thrift v0.0.0-20180902110 replace github.com/keybase/go-crypto v0.0.0-20190523171820-b785b22cc757 => github.com/keybase/go-crypto v0.0.0-20190416182011-b785b22cc757 -go 1.20 +go 1.21 From 83a92e0e6d8d7b9d7b254d7d5670b2fb0737a388 Mon Sep 17 00:00:00 2001 From: Andrew Starr-Bochicchio Date: Fri, 8 Sep 2023 13:56:24 -0400 Subject: [PATCH 4/7] database_user: Prevent creating multiple users for the same cluster in parallel. (#1027) * database_user: Prevent creating multiple users for the same cluster in parallel. * Run terrafmt --- .../database/resource_database_user.go | 13 ++++ .../database/resource_database_user_test.go | 75 +++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/digitalocean/database/resource_database_user.go b/digitalocean/database/resource_database_user.go index 1ba5ee5a7..6a32181a6 100644 --- a/digitalocean/database/resource_database_user.go +++ b/digitalocean/database/resource_database_user.go @@ -9,11 +9,14 @@ import ( "github.com/digitalocean/godo" "github.com/digitalocean/terraform-provider-digitalocean/digitalocean/config" + "github.com/digitalocean/terraform-provider-digitalocean/internal/mutexkv" "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" ) +var mutexKV = mutexkv.NewMutexKV() + func ResourceDigitalOceanDatabaseUser() *schema.Resource { return &schema.Resource{ CreateContext: resourceDigitalOceanDatabaseUserCreate, @@ -78,6 +81,11 @@ func resourceDigitalOceanDatabaseUserCreate(ctx context.Context, d *schema.Resou } } + // Prevent parallel creation of users for same cluster. + key := fmt.Sprintf("digitalocean_database_cluster/%s/users", clusterID) + mutexKV.Lock(key) + defer mutexKV.Unlock(key) + log.Printf("[DEBUG] Database User create configuration: %#v", opts) user, _, err := client.Databases.CreateUser(context.Background(), clusterID, opts) if err != nil { @@ -155,6 +163,11 @@ func resourceDigitalOceanDatabaseUserDelete(ctx context.Context, d *schema.Resou clusterID := d.Get("cluster_id").(string) name := d.Get("name").(string) + // Prevent parallel deletion of users for same cluster. + key := fmt.Sprintf("digitalocean_database_cluster/%s/users", clusterID) + mutexKV.Lock(key) + defer mutexKV.Unlock(key) + log.Printf("[INFO] Deleting Database User: %s", d.Id()) _, err := client.Databases.DeleteUser(context.Background(), clusterID, name) if err != nil { diff --git a/digitalocean/database/resource_database_user_test.go b/digitalocean/database/resource_database_user_test.go index 19d7d9239..460e3b92b 100644 --- a/digitalocean/database/resource_database_user_test.go +++ b/digitalocean/database/resource_database_user_test.go @@ -78,6 +78,46 @@ func TestAccDigitalOceanDatabaseUser_MongoDB(t *testing.T) { }) } +func TestAccDigitalOceanDatabaseUser_MongoDBMultiUser(t *testing.T) { + databaseClusterName := acceptance.RandomTestName() + users := []string{"foo", "bar", "baz", "one", "two"} + config := fmt.Sprintf(testAccCheckDigitalOceanDatabaseUserConfigMongoMultiUser, + databaseClusterName, + users[0], users[0], + users[1], users[1], + users[2], users[2], + users[3], users[3], + users[4], users[4], + ) + userResourceNames := make(map[string]string, len(users)) + for _, u := range users { + userResourceNames[u] = fmt.Sprintf("digitalocean_database_user.%s", u) + } + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acceptance.TestAccPreCheck(t) }, + ProviderFactories: acceptance.TestAccProviderFactories, + CheckDestroy: testAccCheckDigitalOceanDatabaseUserDestroy, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + userResourceNames[users[0]], "name", users[0]), + resource.TestCheckResourceAttr( + userResourceNames[users[1]], "name", users[1]), + resource.TestCheckResourceAttr( + userResourceNames[users[2]], "name", users[2]), + resource.TestCheckResourceAttr( + userResourceNames[users[3]], "name", users[3]), + resource.TestCheckResourceAttr( + userResourceNames[users[4]], "name", users[4]), + ), + }, + }, + }) +} + func TestAccDigitalOceanDatabaseUser_MySQLAuth(t *testing.T) { var databaseUser godo.DatabaseUser databaseClusterName := acceptance.RandomTestName() @@ -270,6 +310,41 @@ resource "digitalocean_database_user" "foobar_user" { name = "%s" }` +const testAccCheckDigitalOceanDatabaseUserConfigMongoMultiUser = ` +resource "digitalocean_database_cluster" "foobar" { + name = "%s" + engine = "mongodb" + version = "4" + size = "db-s-1vcpu-1gb" + region = "nyc1" + node_count = 1 +} + +resource "digitalocean_database_user" "%s" { + cluster_id = digitalocean_database_cluster.foobar.id + name = "%s" +} + +resource "digitalocean_database_user" "%s" { + cluster_id = digitalocean_database_cluster.foobar.id + name = "%s" +} + +resource "digitalocean_database_user" "%s" { + cluster_id = digitalocean_database_cluster.foobar.id + name = "%s" +} + +resource "digitalocean_database_user" "%s" { + cluster_id = digitalocean_database_cluster.foobar.id + name = "%s" +} + +resource "digitalocean_database_user" "%s" { + cluster_id = digitalocean_database_cluster.foobar.id + name = "%s" +}` + const testAccCheckDigitalOceanDatabaseUserConfigMySQLAuth = ` resource "digitalocean_database_cluster" "foobar" { name = "%s" From acafbcf810815ca133b9733473dada7fdac906fe Mon Sep 17 00:00:00 2001 From: danaelhe <42972711+danaelhe@users.noreply.github.com> Date: Fri, 8 Sep 2023 18:21:12 -0400 Subject: [PATCH 5/7] Docs: Update Version of Postgress DB (#1014) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Docs: Update Version of Postgress DB When running the example as is, you get a: `│ Error: Error creating database cluster: POST https://api.digitalocean.com/v2/databases: 422 (request "4f7f-a1a4-") invalid cluster engine version` changing the version to 13 worked fine. * Update database_cluster.md --------- Co-authored-by: Andrew Starr-Bochicchio --- docs/resources/database_cluster.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/resources/database_cluster.md b/docs/resources/database_cluster.md index 142f7092f..3b675850e 100644 --- a/docs/resources/database_cluster.md +++ b/docs/resources/database_cluster.md @@ -13,7 +13,7 @@ Provides a DigitalOcean database cluster resource. resource "digitalocean_database_cluster" "postgres-example" { name = "example-postgres-cluster" engine = "pg" - version = "11" + version = "15" size = "db-s-1vcpu-1gb" region = "nyc1" node_count = 1 From de32434c0f8da55583265e8efb7d902779adbc93 Mon Sep 17 00:00:00 2001 From: Andrew Starr-Bochicchio Date: Mon, 11 Sep 2023 10:16:38 -0400 Subject: [PATCH 6/7] database user: Remove unneeded GET request post-create. (#1028) --- .../database/resource_database_user.go | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/digitalocean/database/resource_database_user.go b/digitalocean/database/resource_database_user.go index 6a32181a6..aecdaee97 100644 --- a/digitalocean/database/resource_database_user.go +++ b/digitalocean/database/resource_database_user.go @@ -95,11 +95,9 @@ func resourceDigitalOceanDatabaseUserCreate(ctx context.Context, d *schema.Resou d.SetId(makeDatabaseUserID(clusterID, user.Name)) log.Printf("[INFO] Database User Name: %s", user.Name) - // MongoDB clusters only return the password in response to the initial POST. - // So we need to set it here before any subsequent GETs. - d.Set("password", user.Password) + setDatabaseUserAttributes(d, user) - return resourceDigitalOceanDatabaseUserRead(ctx, d, meta) + return nil } func resourceDigitalOceanDatabaseUserRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { @@ -120,8 +118,21 @@ func resourceDigitalOceanDatabaseUserRead(ctx context.Context, d *schema.Resourc return diag.Errorf("Error retrieving Database User: %s", err) } - d.Set("role", user.Role) - // This will be blank for MongoDB clusters. Don't overwrite the password set on create. + setDatabaseUserAttributes(d, user) + + return nil +} + +func setDatabaseUserAttributes(d *schema.ResourceData, user *godo.DatabaseUser) { + // Default to "normal" when not set. + if user.Role == "" { + d.Set("role", "normal") + } else { + d.Set("role", user.Role) + } + + // This will be blank when GETing MongoDB clusters post-create. + // Don't overwrite the password set on create. if user.Password != "" { d.Set("password", user.Password) } @@ -129,8 +140,6 @@ func resourceDigitalOceanDatabaseUserRead(ctx context.Context, d *schema.Resourc if user.MySQLSettings != nil { d.Set("mysql_auth_plugin", user.MySQLSettings.AuthPlugin) } - - return nil } func resourceDigitalOceanDatabaseUserUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { From 54006ecc7f859ccd5436d070c00acae73ba8ec8b Mon Sep 17 00:00:00 2001 From: Andrew Starr-Bochicchio Date: Mon, 11 Sep 2023 12:15:44 -0400 Subject: [PATCH 7/7] Prep v2.30.0 release. (#1029) --- CHANGELOG.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6fb4fe14..cb579ea71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,27 @@ +# 2.30.0 + +FEATURES: + +- **New Resource:** `digitalocean_spaces_bucket_cors_configuration` (#1021) - @danaelhe + +IMPROVEMENTS: + +- `provider`: Enable retries for requests that fail with a 429 or 500-level error by default (#1016). - @danaelhe + +BUG FIXES: + +- `digitalocean_database_user`: Prevent creating multiple users for the same cluster in parallel (#1027). - @andrewsomething +- `digitalocean_database_user`: Remove unneeded GET request post-create (#1028). - @andrewsomething + +MISC: + +- `docs`: Make it clear that volume name has to start with a letter (#1024). - @ahasna +- `docs`: Update Postgres version in example (#1014). - @danaelhe +- `provider`: Bump Go version to v1.21.0 (#1025). - @andrewsomething +- `provider`: Update godo to v1.102.1 (#1020). - @danaelhe +- `provider`: Update godo dependency to v1.102.0 (#1018). - @danaelhe +- `provider`: Update godo dependency to v1.101.0 (#1017.) - @danaelhe + # 2.29.0 FEATURES: