diff --git a/.changelog/19292.txt b/.changelog/19292.txt new file mode 100644 index 00000000000..312e5bd0c7a --- /dev/null +++ b/.changelog/19292.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +resource/aws_dynamodb_table: Allows restoring to point-in-time +``` diff --git a/internal/service/dynamodb/table.go b/internal/service/dynamodb/table.go index c87af2efa1d..cd63b8a7271 100644 --- a/internal/service/dynamodb/table.go +++ b/internal/service/dynamodb/table.go @@ -7,6 +7,7 @@ import ( "log" "reflect" "strings" + "time" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/dynamodb" @@ -79,7 +80,8 @@ func ResourceTable() *schema.Resource { }, "attribute": { Type: schema.TypeSet, - Required: true, + Optional: true, + Computed: true, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "name": { @@ -153,7 +155,8 @@ func ResourceTable() *schema.Resource { }, "hash_key": { Type: schema.TypeString, - Required: true, + Optional: true, + Computed: true, ForceNew: true, }, "local_secondary_index": { @@ -220,6 +223,7 @@ func ResourceTable() *schema.Resource { "read_capacity": { Type: schema.TypeInt, Optional: true, + Computed: true, }, "replica": { Type: schema.TypeSet, @@ -292,6 +296,7 @@ func ResourceTable() *schema.Resource { "ttl": { Type: schema.TypeList, Optional: true, + Computed: true, MaxItems: 1, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ @@ -316,6 +321,7 @@ func ResourceTable() *schema.Resource { }, "write_capacity": { Type: schema.TypeInt, + Computed: true, Optional: true, }, "table_class": { @@ -323,6 +329,22 @@ func ResourceTable() *schema.Resource { Optional: true, ValidateFunc: validation.StringInSlice(dynamodb.TableClass_Values(), false), }, + "restore_source_name": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + "restore_to_latest_time": { + Type: schema.TypeBool, + Optional: true, + ForceNew: true, + }, + "restore_date_time": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ValidateFunc: verify.ValidUTCTimestamp, + }, }, } } @@ -341,125 +363,197 @@ func resourceTableCreate(d *schema.ResourceData, meta interface{}) error { log.Printf("[DEBUG] Creating DynamoDB table with key schema: %#v", keySchemaMap) - req := &dynamodb.CreateTableInput{ - TableName: aws.String(d.Get("name").(string)), - BillingMode: aws.String(d.Get("billing_mode").(string)), - KeySchema: expandDynamoDbKeySchema(keySchemaMap), - Tags: Tags(tags.IgnoreAWS()), - } - - billingMode := d.Get("billing_mode").(string) - capacityMap := map[string]interface{}{ - "write_capacity": d.Get("write_capacity"), - "read_capacity": d.Get("read_capacity"), - } + if _, ok := d.GetOk("restore_source_name"); ok { - if err := validateDynamoDbProvisionedThroughput(capacityMap, billingMode); err != nil { - return err - } + req := &dynamodb.RestoreTableToPointInTimeInput{ + TargetTableName: aws.String(d.Get("name").(string)), + SourceTableName: aws.String(d.Get("restore_source_name").(string)), + } - req.ProvisionedThroughput = expandDynamoDbProvisionedThroughput(capacityMap, billingMode) + if v, ok := d.GetOk("restore_date_time"); ok { + t, _ := time.Parse(time.RFC3339, v.(string)) + req.RestoreDateTime = aws.Time(t) + } - if v, ok := d.GetOk("attribute"); ok { - aSet := v.(*schema.Set) - req.AttributeDefinitions = expandDynamoDbAttributes(aSet.List()) - } + if attr, ok := d.GetOk("restore_to_latest_time"); ok { + req.UseLatestRestorableTime = aws.Bool(attr.(bool)) + } - if v, ok := d.GetOk("local_secondary_index"); ok { - lsiSet := v.(*schema.Set) - req.LocalSecondaryIndexes = expandDynamoDbLocalSecondaryIndexes(lsiSet.List(), keySchemaMap) - } + if v, ok := d.GetOk("local_secondary_index"); ok { + lsiSet := v.(*schema.Set) + req.LocalSecondaryIndexOverride = expandDynamoDbLocalSecondaryIndexes(lsiSet.List(), keySchemaMap) + } - if v, ok := d.GetOk("global_secondary_index"); ok { - globalSecondaryIndexes := []*dynamodb.GlobalSecondaryIndex{} - gsiSet := v.(*schema.Set) + billingModeOverride := d.Get("billing_mode").(string) - for _, gsiObject := range gsiSet.List() { - gsi := gsiObject.(map[string]interface{}) - if err := validateDynamoDbProvisionedThroughput(gsi, billingMode); err != nil { - return fmt.Errorf("failed to create GSI: %v", err) + if _, ok := d.GetOk("write_capacity"); ok { + if _, ok := d.GetOk("read_capacity"); ok { + capacityMap := map[string]interface{}{ + "write_capacity": d.Get("write_capacity"), + "read_capacity": d.Get("read_capacity"), + } + req.ProvisionedThroughputOverride = expandDynamoDbProvisionedThroughput(capacityMap, billingModeOverride) } - - gsiObject := expandDynamoDbGlobalSecondaryIndex(gsi, billingMode) - globalSecondaryIndexes = append(globalSecondaryIndexes, gsiObject) } - req.GlobalSecondaryIndexes = globalSecondaryIndexes - } - if v, ok := d.GetOk("stream_enabled"); ok { - req.StreamSpecification = &dynamodb.StreamSpecification{ - StreamEnabled: aws.Bool(v.(bool)), - StreamViewType: aws.String(d.Get("stream_view_type").(string)), + if v, ok := d.GetOk("local_secondary_index"); ok { + lsiSet := v.(*schema.Set) + req.LocalSecondaryIndexOverride = expandDynamoDbLocalSecondaryIndexes(lsiSet.List(), keySchemaMap) } - } - if v, ok := d.GetOk("server_side_encryption"); ok { - req.SSESpecification = expandDynamoDbEncryptAtRestOptions(v.([]interface{})) - } + if v, ok := d.GetOk("global_secondary_index"); ok { + globalSecondaryIndexes := []*dynamodb.GlobalSecondaryIndex{} + gsiSet := v.(*schema.Set) - if v, ok := d.GetOk("table_class"); ok { - req.TableClass = aws.String(v.(string)) - } + for _, gsiObject := range gsiSet.List() { + gsi := gsiObject.(map[string]interface{}) + if err := validateDynamoDbProvisionedThroughput(gsi, billingModeOverride); err != nil { + return fmt.Errorf("failed to create GSI: %v", err) + } - var output *dynamodb.CreateTableOutput - var requiresTagging bool - err := resource.Retry(createTableTimeout, func() *resource.RetryError { - var err error - output, err = conn.CreateTable(req) - if err != nil { - if tfawserr.ErrMessageContains(err, "ThrottlingException", "") { - return resource.RetryableError(err) - } - if tfawserr.ErrMessageContains(err, dynamodb.ErrCodeLimitExceededException, "can be created, updated, or deleted simultaneously") { - return resource.RetryableError(err) + gsiObject := expandDynamoDbGlobalSecondaryIndex(gsi, billingModeOverride) + globalSecondaryIndexes = append(globalSecondaryIndexes, gsiObject) } - if tfawserr.ErrMessageContains(err, dynamodb.ErrCodeLimitExceededException, "indexed tables that can be created simultaneously") { - return resource.RetryableError(err) + req.GlobalSecondaryIndexOverride = globalSecondaryIndexes + } + + if v, ok := d.GetOk("server_side_encryption"); ok { + req.SSESpecificationOverride = expandDynamoDbEncryptAtRestOptions(v.([]interface{})) + } + + var output *dynamodb.RestoreTableToPointInTimeOutput + err := resource.Retry(createTableTimeout, func() *resource.RetryError { + var err error + output, err = conn.RestoreTableToPointInTime(req) + if err != nil { + if tfawserr.ErrCodeEquals(err, "ThrottlingException") { + return resource.RetryableError(err) + } + if tfawserr.ErrMessageContains(err, dynamodb.ErrCodeLimitExceededException, "can be created, updated, or deleted simultaneously") { + return resource.RetryableError(err) + } + if tfawserr.ErrMessageContains(err, dynamodb.ErrCodeLimitExceededException, "indexed tables that can be created simultaneously") { + return resource.RetryableError(err) + } + + return resource.NonRetryableError(err) } - // AWS GovCloud (US) and others may reply with the following until their API is updated: - // ValidationException: One or more parameter values were invalid: Unsupported input parameter BillingMode - if tfawserr.ErrMessageContains(err, "ValidationException", "Unsupported input parameter BillingMode") { - req.BillingMode = nil - return resource.RetryableError(err) + return nil + }) + + if tfresource.TimedOut(err) { + output, err = conn.RestoreTableToPointInTime(req) + } + + if err != nil { + return fmt.Errorf("error creating DynamoDB Table: %w", err) + } + + if output == nil || output.TableDescription == nil { + return fmt.Errorf("error creating DynamoDB Table: empty response") + } + + } else { + req := &dynamodb.CreateTableInput{ + TableName: aws.String(d.Get("name").(string)), + BillingMode: aws.String(d.Get("billing_mode").(string)), + KeySchema: expandDynamoDbKeySchema(keySchemaMap), + Tags: Tags(tags.IgnoreAWS()), + } + + billingMode := d.Get("billing_mode").(string) + + capacityMap := map[string]interface{}{ + "write_capacity": d.Get("write_capacity"), + "read_capacity": d.Get("read_capacity"), + } + + if err := validateDynamoDbProvisionedThroughput(capacityMap, billingMode); err != nil { + return err + } + + req.ProvisionedThroughput = expandDynamoDbProvisionedThroughput(capacityMap, billingMode) + + if v, ok := d.GetOk("attribute"); ok { + aSet := v.(*schema.Set) + req.AttributeDefinitions = expandDynamoDbAttributes(aSet.List()) + } + + if v, ok := d.GetOk("local_secondary_index"); ok { + lsiSet := v.(*schema.Set) + req.LocalSecondaryIndexes = expandDynamoDbLocalSecondaryIndexes(lsiSet.List(), keySchemaMap) + } + + if v, ok := d.GetOk("global_secondary_index"); ok { + globalSecondaryIndexes := []*dynamodb.GlobalSecondaryIndex{} + gsiSet := v.(*schema.Set) + + for _, gsiObject := range gsiSet.List() { + gsi := gsiObject.(map[string]interface{}) + if err := validateDynamoDbProvisionedThroughput(gsi, billingMode); err != nil { + return fmt.Errorf("failed to create GSI: %v", err) + } + + gsiObject := expandDynamoDbGlobalSecondaryIndex(gsi, billingMode) + globalSecondaryIndexes = append(globalSecondaryIndexes, gsiObject) } - // AWS GovCloud (US) and others may reply with the following until their API is updated: - // ValidationException: Unsupported input parameter Tags - if tfawserr.ErrMessageContains(err, "ValidationException", "Unsupported input parameter Tags") { - req.Tags = nil - requiresTagging = true - return resource.RetryableError(err) + req.GlobalSecondaryIndexes = globalSecondaryIndexes + } + + if v, ok := d.GetOk("stream_enabled"); ok { + req.StreamSpecification = &dynamodb.StreamSpecification{ + StreamEnabled: aws.Bool(v.(bool)), + StreamViewType: aws.String(d.Get("stream_view_type").(string)), } + } - return resource.NonRetryableError(err) + if v, ok := d.GetOk("server_side_encryption"); ok { + req.SSESpecification = expandDynamoDbEncryptAtRestOptions(v.([]interface{})) } - return nil - }) - if tfresource.TimedOut(err) { - output, err = conn.CreateTable(req) - } + if v, ok := d.GetOk("table_class"); ok { + req.TableClass = aws.String(v.(string)) + } - if err != nil { - return fmt.Errorf("error creating DynamoDB Table: %w", err) - } + var output *dynamodb.CreateTableOutput + err := resource.Retry(createTableTimeout, func() *resource.RetryError { + var err error + output, err = conn.CreateTable(req) + if err != nil { + if tfawserr.ErrCodeEquals(err, "ThrottlingException", "") { + return resource.RetryableError(err) + } + if tfawserr.ErrMessageContains(err, dynamodb.ErrCodeLimitExceededException, "can be created, updated, or deleted simultaneously") { + return resource.RetryableError(err) + } + if tfawserr.ErrMessageContains(err, dynamodb.ErrCodeLimitExceededException, "indexed tables that can be created simultaneously") { + return resource.RetryableError(err) + } + + return resource.NonRetryableError(err) + } + return nil + }) + + if tfresource.TimedOut(err) { + output, err = conn.CreateTable(req) + } + + if err != nil { + return fmt.Errorf("error creating DynamoDB Table: %w", err) + } - if output == nil || output.TableDescription == nil { - return fmt.Errorf("error creating DynamoDB Table: empty response") + if output == nil || output.TableDescription == nil { + return fmt.Errorf("error creating DynamoDB Table: empty response") + } } - d.SetId(aws.StringValue(output.TableDescription.TableName)) - d.Set("arn", output.TableDescription.TableArn) + d.SetId(d.Get("name").(string)) if _, err := waitDynamoDBTableActive(conn, d.Id()); err != nil { return fmt.Errorf("error waiting for creation of DynamoDB table (%s): %w", d.Id(), err) } - if requiresTagging { - if err := UpdateTags(conn, d.Get("arn").(string), nil, tags); err != nil { - return fmt.Errorf("error adding DynamoDB Table (%s) tags: %w", d.Id(), err) - } - } - if d.Get("ttl.0.enabled").(bool) { if err := updateDynamoDbTimeToLive(d.Id(), d.Get("ttl").([]interface{}), conn); err != nil { return fmt.Errorf("error enabling DynamoDB Table (%s) Time to Live: %w", d.Id(), err) @@ -875,7 +969,7 @@ func createDynamoDbReplicas(tableName string, tfList []interface{}, conn *dynamo err := resource.Retry(replicaUpdateTimeout, func() *resource.RetryError { _, err := conn.UpdateTable(input) if err != nil { - if tfawserr.ErrMessageContains(err, "ThrottlingException", "") { + if tfawserr.ErrCodeEquals(err, "ThrottlingException") { return resource.RetryableError(err) } if tfawserr.ErrMessageContains(err, dynamodb.ErrCodeLimitExceededException, "can be created, updated, or deleted simultaneously") { @@ -1169,7 +1263,7 @@ func deleteDynamoDbReplicas(tableName string, tfList []interface{}, conn *dynamo err := resource.Retry(updateTableTimeout, func() *resource.RetryError { _, err := conn.UpdateTable(input) if err != nil { - if tfawserr.ErrMessageContains(err, "ThrottlingException", "") { + if tfawserr.ErrCodeEquals(err, "ThrottlingException") { return resource.RetryableError(err) } if tfawserr.ErrMessageContains(err, dynamodb.ErrCodeLimitExceededException, "can be created, updated, or deleted simultaneously") { @@ -1511,9 +1605,10 @@ func expandDynamoDbEncryptAtRestOptions(vOptions []interface{}) *dynamodb.SSESpe func validateDynamoDbTableAttributes(d *schema.ResourceDiff) error { // Collect all indexed attributes - primaryHashKey := d.Get("hash_key").(string) - indexedAttributes := map[string]bool{ - primaryHashKey: true, + indexedAttributes := map[string]bool{} + + if v, ok := d.GetOk("hash_key"); ok { + indexedAttributes[v.(string)] = true } if v, ok := d.GetOk("range_key"); ok { indexedAttributes[v.(string)] = true diff --git a/internal/service/dynamodb/table_test.go b/internal/service/dynamodb/table_test.go index 3fd34149bdf..9e0c541b628 100644 --- a/internal/service/dynamodb/table_test.go +++ b/internal/service/dynamodb/table_test.go @@ -333,6 +333,7 @@ func TestAccDynamoDBTable_basic(t *testing.T) { Config: testAccConfig_basic(rName), Check: resource.ComposeTestCheckFunc( testAccCheckInitialTableExists(resourceName, &conf), + acctest.CheckResourceAttrRegionalARN(resourceName, "arn", "dynamodb", fmt.Sprintf("table/%s", rName)), resource.TestCheckResourceAttr(resourceName, "name", rName), resource.TestCheckResourceAttr(resourceName, "read_capacity", "1"), resource.TestCheckResourceAttr(resourceName, "write_capacity", "1"), @@ -1456,6 +1457,76 @@ func TestAccDynamoDBTable_tableClassInfrequentAccess(t *testing.T) { }) } +func TestAccDynamoDBTable_backup_encryption(t *testing.T) { + var confBYOK dynamodb.DescribeTableOutput + resourceName := "aws_dynamodb_table.test" + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + kmsKeyResourceName := "aws_kms_key.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ErrorCheck: acctest.ErrorCheck(t, dynamodb.EndpointsID), + Providers: acctest.Providers, + CheckDestroy: testAccCheckTableDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSDynamoDbBackupConfigInitialStateWithEncryption(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckInitialTableExists(resourceName, &confBYOK), + resource.TestCheckResourceAttr(resourceName, "server_side_encryption.#", "1"), + resource.TestCheckResourceAttr(resourceName, "server_side_encryption.0.enabled", "true"), + resource.TestCheckResourceAttrPair(resourceName, "server_side_encryption.0.kms_key_arn", kmsKeyResourceName, "arn"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{ + "restore_to_latest_time", + "restore_date_time", + "restore_source_name", + }, + }, + }, + }) +} + +func TestAccDynamoDBTable_backup_overrideEncryption(t *testing.T) { + var confBYOK dynamodb.DescribeTableOutput + resourceName := "aws_dynamodb_table.test" + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + kmsKeyResourceName := "aws_kms_key.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ErrorCheck: acctest.ErrorCheck(t, dynamodb.EndpointsID), + Providers: acctest.Providers, + CheckDestroy: testAccCheckTableDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSDynamoDbBackupConfigInitialStateWithOverrideEncryption(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckInitialTableExists(resourceName, &confBYOK), + resource.TestCheckResourceAttr(resourceName, "server_side_encryption.#", "1"), + resource.TestCheckResourceAttr(resourceName, "server_side_encryption.0.enabled", "true"), + resource.TestCheckResourceAttrPair(resourceName, "server_side_encryption.0.kms_key_arn", kmsKeyResourceName, "arn"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{ + "restore_to_latest_time", + "restore_date_time", + "restore_source_name", + }, + }, + }, + }) +} + func testAccCheckTableDestroy(s *terraform.State) error { conn := acctest.Provider.Meta().(*conns.AWSClient).DynamoDBConn @@ -2516,3 +2587,77 @@ resource "aws_dynamodb_table" "test" { } `, rName, tableClass) } + +func testAccAWSDynamoDbBackupConfigInitialStateWithOverrideEncryption(rName string) string { + return fmt.Sprintf(` +resource "aws_dynamodb_table" "source" { + name = "%[1]s-source" + read_capacity = 2 + write_capacity = 2 + hash_key = "TestTableHashKey" + + attribute { + name = "TestTableHashKey" + type = "S" + } + + point_in_time_recovery { + enabled = true + } + + server_side_encryption { + enabled = false + } +} + +resource "aws_kms_key" "test" { + description = %[1]q +} + +resource "aws_dynamodb_table" "test" { + name = "%[1]s-target" + restore_source_name = aws_dynamodb_table.source.name + restore_to_latest_time = true + + server_side_encryption { + enabled = true + kms_key_arn = aws_kms_key.test.arn + } +} +`, rName) +} + +func testAccAWSDynamoDbBackupConfigInitialStateWithEncryption(rName string) string { + return fmt.Sprintf(` +resource "aws_dynamodb_table" "source" { + name = "%[1]s-source" + read_capacity = 2 + write_capacity = 2 + hash_key = "TestTableHashKey" + + attribute { + name = "TestTableHashKey" + type = "S" + } + + point_in_time_recovery { + enabled = true + } + + server_side_encryption { + enabled = true + kms_key_arn = aws_kms_key.test.arn + } +} + +resource "aws_kms_key" "test" { + description = %[1]q +} + +resource "aws_dynamodb_table" "test" { + name = "%[1]s-target" + restore_source_name = aws_dynamodb_table.source.name + restore_to_latest_time = true +} +`, rName) +} diff --git a/website/docs/r/dynamodb_table.html.markdown b/website/docs/r/dynamodb_table.html.markdown index 157ecb41c2f..0657b13c5c6 100644 --- a/website/docs/r/dynamodb_table.html.markdown +++ b/website/docs/r/dynamodb_table.html.markdown @@ -113,13 +113,16 @@ definition after you have created the resource. * `global_secondary_index` - (Optional) Describe a GSI for the table; subject to the normal limits on the number of GSIs, projected attributes, etc. +* `point_in_time_recovery` - (Optional) Enable point-in-time recovery options. * `replica` - (Optional) Configuration block(s) with [DynamoDB Global Tables V2 (version 2019.11.21)](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/globaltables.V2.html) replication configurations. Detailed below. +* `restore_source_name` - (Optional) The name of the table to restore. Must match the name of an existing table. +* `restore_to_latest_time` - (Optional) If set, restores table to the most recent point-in-time recovery point. +* `restore_date_time` - (Optional) The time of the point-in-time recovery point to restore. * `stream_enabled` - (Optional) Indicates whether Streams are to be enabled (true) or disabled (false). * `stream_view_type` - (Optional) When an item in the table is modified, StreamViewType determines what information is written to the table's stream. Valid values are `KEYS_ONLY`, `NEW_IMAGE`, `OLD_IMAGE`, `NEW_AND_OLD_IMAGES`. * `server_side_encryption` - (Optional) Encryption at rest options. AWS DynamoDB tables are automatically encrypted at rest with an AWS owned Customer Master Key if this argument isn't specified. -* `tags` - (Optional) A map of tags to populate on the created table. If configured with a provider [`default_tags` configuration block](/docs/providers/aws/index.html#default_tags-configuration-block) present, tags with matching keys will overwrite those defined at the provider-level. -* `point_in_time_recovery` - (Optional) Point-in-time recovery options. * `table_class` - (Optional) The storage class of the table. Valid values are `STANDARD` and `STANDARD_INFREQUENT_ACCESS`. +* `tags` - (Optional) A map of tags to populate on the created table. If configured with a provider [`default_tags` configuration block](/docs/providers/aws/index.html#default_tags-configuration-block) present, tags with matching keys will overwrite those defined at the provider-level. ### Timeouts