diff --git a/.changelog/23947.txt b/.changelog/23947.txt new file mode 100644 index 00000000000..2dc5c08dba1 --- /dev/null +++ b/.changelog/23947.txt @@ -0,0 +1,3 @@ +```release-note:new-resource +aws_dynamodb_contributor_insights +``` \ No newline at end of file diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 2abe46f3859..ade8ff12610 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -1195,6 +1195,7 @@ func Provider() *schema.Provider { "aws_directory_service_directory": ds.ResourceDirectory(), "aws_directory_service_log_subscription": ds.ResourceLogSubscription(), + "aws_dynamodb_contributor_insights": dynamodb.ResourceContributorInsights(), "aws_dynamodb_global_table": dynamodb.ResourceGlobalTable(), "aws_dynamodb_kinesis_streaming_destination": dynamodb.ResourceKinesisStreamingDestination(), "aws_dynamodb_table": dynamodb.ResourceTable(), diff --git a/internal/service/dynamodb/contributor_insights.go b/internal/service/dynamodb/contributor_insights.go new file mode 100644 index 00000000000..ffc6a68d8eb --- /dev/null +++ b/internal/service/dynamodb/contributor_insights.go @@ -0,0 +1,156 @@ +package dynamodb + +import ( + "context" + "fmt" + "log" + "strings" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/dynamodb" + "github.com/hashicorp/aws-sdk-go-base/v2/awsv1shim/v2/tfawserr" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-provider-aws/internal/conns" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" +) + +func ResourceContributorInsights() *schema.Resource { + return &schema.Resource{ + CreateWithoutTimeout: resourceContributorInsightsCreate, + ReadWithoutTimeout: resourceContributorInsightsRead, + DeleteWithoutTimeout: resourceContributorInsightsDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(5 * time.Minute), + Delete: schema.DefaultTimeout(5 * time.Minute), + }, + + Schema: map[string]*schema.Schema{ + "index_name": { + Type: schema.TypeString, + ForceNew: true, + Optional: true, + }, + "table_name": { + Type: schema.TypeString, + ForceNew: true, + Required: true, + }, + }, + } +} + +func resourceContributorInsightsCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).DynamoDBConn + + input := &dynamodb.UpdateContributorInsightsInput{ + ContributorInsightsAction: aws.String(dynamodb.ContributorInsightsActionEnable), + } + + if v, ok := d.GetOk("table_name"); ok { + input.TableName = aws.String(v.(string)) + } + + var indexName string + if v, ok := d.GetOk("index_name"); ok { + input.IndexName = aws.String(v.(string)) + indexName = v.(string) + } + + output, err := conn.UpdateContributorInsightsWithContext(ctx, input) + if err != nil { + return diag.Errorf("creating DynamoDB ContributorInsights for table (%s): %s", d.Get("table_name").(string), err) + } + + id := EncodeContributorInsightsID(aws.StringValue(output.TableName), indexName, meta.(*conns.AWSClient).AccountID) + d.SetId(id) + + if err := waitContributorInsightsCreated(ctx, conn, aws.StringValue(output.TableName), indexName, d.Timeout(schema.TimeoutCreate)); err != nil { + return diag.Errorf("waiting for DynamoDB ContributorInsights (%s) create: %s", d.Id(), err) + } + + return resourceContributorInsightsRead(ctx, d, meta) +} + +func resourceContributorInsightsRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).DynamoDBConn + + tableName, indexName, err := DecodeContributorInsightsID(d.Id()) + if err != nil { + return diag.Errorf("unable to decode DynamoDB ContributorInsights ID (%s): %s", d.Id(), err) + } + + out, err := FindContributorInsights(ctx, conn, tableName, indexName) + + if !d.IsNewResource() && tfresource.NotFound(err) { + log.Printf("[WARN] DynamoDB ContributorInsights (%s) not found, removing from state", d.Id()) + d.SetId("") + return nil + } + + if err != nil { + return diag.Errorf("reading DynamoDB ContributorInsights (%s): %s", d.Id(), err) + } + + d.Set("index_name", out.IndexName) + d.Set("table_name", out.TableName) + + return nil +} + +func resourceContributorInsightsDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).DynamoDBConn + + log.Printf("[INFO] Deleting DynamoDB ContributorInsights %s", d.Id()) + + tableName, indexName, err := DecodeContributorInsightsID(d.Id()) + if err != nil { + return diag.Errorf("unable to decode DynamoDB ContributorInsights ID (%s): %s", d.Id(), err) + } + + input := &dynamodb.UpdateContributorInsightsInput{ + ContributorInsightsAction: aws.String(dynamodb.ContributorInsightsActionDisable), + TableName: aws.String(tableName), + } + + if indexName != "" { + input.IndexName = aws.String(indexName) + } + + _, err = conn.UpdateContributorInsightsWithContext(ctx, input) + + if tfawserr.ErrCodeEquals(err, dynamodb.ErrCodeResourceNotFoundException) { + return nil + } + + if err != nil { + return diag.Errorf("deleting DynamoDB ContributorInsights (%s): %s", d.Id(), err) + } + + if err := waitContributorInsightsDeleted(ctx, conn, tableName, indexName, d.Timeout(schema.TimeoutDelete)); err != nil { + return diag.Errorf("waiting for DynamoDB ContributorInsights (%s) to be deleted: %s", d.Id(), err) + } + + return nil +} + +func EncodeContributorInsightsID(tableName, indexName, accountID string) string { + return fmt.Sprintf("name:%s/index:%s/%s", tableName, indexName, accountID) +} + +func DecodeContributorInsightsID(id string) (string, string, error) { + idParts := strings.Split(id, "/") + if len(idParts) != 3 || idParts[0] == "" || idParts[2] == "" { + return "", "", fmt.Errorf("expected ID in the form of table_name/account_id, given: %q", id) + } + + tableName := strings.TrimPrefix(idParts[0], "name:") + indexName := strings.TrimPrefix(idParts[1], "index:") + + return tableName, indexName, nil +} diff --git a/internal/service/dynamodb/contributor_insights_test.go b/internal/service/dynamodb/contributor_insights_test.go new file mode 100644 index 00000000000..a91636f38cc --- /dev/null +++ b/internal/service/dynamodb/contributor_insights_test.go @@ -0,0 +1,177 @@ +package dynamodb_test + +import ( + "context" + "fmt" + "log" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/dynamodb" + 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" + tfdynamodb "github.com/hashicorp/terraform-provider-aws/internal/service/dynamodb" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" +) + +func TestAccContributorInsights_basic(t *testing.T) { + var conf dynamodb.DescribeContributorInsightsOutput + rName := fmt.Sprintf("tf-acc-test-%s", sdkacctest.RandString(8)) + indexName := fmt.Sprintf("%s-index", rName) + resourceName := "aws_dynamodb_contributor_insights.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ErrorCheck: acctest.ErrorCheck(t, dynamodb.EndpointsID), + Providers: acctest.Providers, + CheckDestroy: testAccCheckContributorInsightsDestroy, + Steps: []resource.TestStep{ + { + Config: testAccContributorInsightsBasicConfig(rName, ""), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckContributorInsightsExists(resourceName, &conf), + resource.TestCheckResourceAttr(resourceName, "table_name", rName), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccContributorInsightsBasicConfig(rName, indexName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckContributorInsightsExists(resourceName, &conf), + resource.TestCheckResourceAttr(resourceName, "index_name", indexName), + ), + }, + }, + }) +} + +func TestAccContributorInsights_disappears(t *testing.T) { + var conf dynamodb.DescribeContributorInsightsOutput + rName := fmt.Sprintf("tf-acc-test-%s", sdkacctest.RandString(8)) + resourceName := "aws_dynamodb_contributor_insights.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ErrorCheck: acctest.ErrorCheck(t, dynamodb.EndpointsID), + Providers: acctest.Providers, + CheckDestroy: testAccCheckContributorInsightsDestroy, + Steps: []resource.TestStep{ + { + Config: testAccContributorInsightsBasicConfig(rName, ""), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckContributorInsightsExists(resourceName, &conf), + acctest.CheckResourceDisappears(acctest.Provider, tfdynamodb.ResourceContributorInsights(), resourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func testAccContributorInsightsBaseConfig(rName string) string { + return fmt.Sprintf(` +resource "aws_dynamodb_table" "test" { + name = %[1]q + read_capacity = 2 + write_capacity = 2 + hash_key = %[1]q + + attribute { + name = %[1]q + type = "S" + } + + global_secondary_index { + name = "%[1]s-index" + hash_key = %[1]q + projection_type = "ALL" + read_capacity = 1 + write_capacity = 1 + } +} +`, rName) +} + +func testAccContributorInsightsBasicConfig(rName, indexName string) string { + return acctest.ConfigCompose(testAccContributorInsightsBaseConfig(rName), fmt.Sprintf(` +resource "aws_dynamodb_contributor_insights" "test" { + table_name = aws_dynamodb_table.test.name + index_name = %[2]q +} +`, rName, indexName)) +} + +func testAccCheckContributorInsightsExists(n string, ci *dynamodb.DescribeContributorInsightsOutput) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("no DynamodDB Contributor Insights ID is set") + } + + conn := acctest.Provider.Meta().(*conns.AWSClient).DynamoDBConn + + tableName, indexName, err := tfdynamodb.DecodeContributorInsightsID(rs.Primary.ID) + if err != nil { + return err + } + + output, err := tfdynamodb.FindContributorInsights(context.Background(), conn, tableName, indexName) + if err != nil { + return err + } + + ci = output + + return nil + } +} + +func testAccCheckContributorInsightsDestroy(s *terraform.State) error { + conn := acctest.Provider.Meta().(*conns.AWSClient).DynamoDBConn + + for _, rs := range s.RootModule().Resources { + if rs.Type != "aws_dynamodb_contributor_insights" { + continue + } + + log.Printf("[DEBUG] Checking if DynamoDB Contributor Insights %s exists", rs.Primary.ID) + + tableName, indexName, err := tfdynamodb.DecodeContributorInsightsID(rs.Primary.ID) + if err != nil { + return err + } + + in := &dynamodb.DescribeContributorInsightsInput{ + TableName: aws.String(tableName), + } + + if indexName != "" { + in.IndexName = aws.String(indexName) + } + + _, err = tfdynamodb.FindContributorInsights(context.Background(), conn, tableName, indexName) + if err == nil { + return fmt.Errorf("the DynamoDB Contributor Insights %s still exists. Failing", rs.Primary.ID) + } + + // Verify the error is what we want + if tfresource.NotFound(err) { + return nil + } + + return err + } + + return nil +} diff --git a/internal/service/dynamodb/find.go b/internal/service/dynamodb/find.go index 20c2e09c8a8..a03c8816970 100644 --- a/internal/service/dynamodb/find.go +++ b/internal/service/dynamodb/find.go @@ -5,6 +5,9 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/dynamodb" + "github.com/hashicorp/aws-sdk-go-base/v2/awsv1shim/v2/tfawserr" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" ) func FindDynamoDBKinesisDataStreamDestination(ctx context.Context, conn *dynamodb.DynamoDB, streamArn, tableName string) (*dynamodb.KinesisDataStreamDestination, error) { @@ -119,3 +122,39 @@ func FindDynamoDBTTLRDescriptionByTableName(conn *dynamodb.DynamoDB, tableName s return output.TimeToLiveDescription, nil } + +func FindContributorInsights(ctx context.Context, conn *dynamodb.DynamoDB, tableName, indexName string) (*dynamodb.DescribeContributorInsightsOutput, error) { + input := &dynamodb.DescribeContributorInsightsInput{ + TableName: aws.String(tableName), + } + + if indexName != "" { + input.IndexName = aws.String(indexName) + } + + output, err := conn.DescribeContributorInsightsWithContext(ctx, input) + + if tfawserr.ErrCodeEquals(err, dynamodb.ErrCodeResourceNotFoundException) { + return nil, &resource.NotFoundError{ + LastError: err, + LastRequest: input, + } + } + + if err != nil { + return nil, err + } + + if status := aws.StringValue(output.ContributorInsightsStatus); status == dynamodb.ContributorInsightsStatusDisabled { + return nil, &resource.NotFoundError{ + Message: status, + LastRequest: input, + } + } + + if output == nil { + return nil, tfresource.NewEmptyResultError(input) + } + + return output, nil +} diff --git a/internal/service/dynamodb/status.go b/internal/service/dynamodb/status.go index a8d6c5c60eb..d68e4900e54 100644 --- a/internal/service/dynamodb/status.go +++ b/internal/service/dynamodb/status.go @@ -8,6 +8,7 @@ import ( "github.com/aws/aws-sdk-go/service/dynamodb" "github.com/hashicorp/aws-sdk-go-base/v2/awsv1shim/v2/tfawserr" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" ) func statusDynamoDBKinesisStreamingDestination(ctx context.Context, conn *dynamodb.DynamoDB, streamArn, tableName string) resource.StateRefreshFunc { @@ -184,3 +185,23 @@ func statusDynamoDBTableSES(conn *dynamodb.DynamoDB, tableName string) resource. return table, aws.StringValue(table.SSEDescription.Status), nil } } + +func statusContributorInsights(ctx context.Context, conn *dynamodb.DynamoDB, tableName, indexName string) resource.StateRefreshFunc { + return func() (interface{}, string, error) { + insight, err := FindContributorInsights(ctx, conn, tableName, indexName) + + if tfresource.NotFound(err) { + return nil, "", nil + } + + if err != nil { + return nil, "", err + } + + if insight == nil { + return nil, "", nil + } + + return insight, aws.StringValue(insight.ContributorInsightsStatus), nil + } +} diff --git a/internal/service/dynamodb/wait.go b/internal/service/dynamodb/wait.go index 0e39ada97dd..63bec4592db 100644 --- a/internal/service/dynamodb/wait.go +++ b/internal/service/dynamodb/wait.go @@ -258,3 +258,29 @@ func waitDynamoDBSSEUpdated(conn *dynamodb.DynamoDB, tableName string) (*dynamod return nil, err } + +func waitContributorInsightsCreated(ctx context.Context, conn *dynamodb.DynamoDB, tableName, indexName string, timeout time.Duration) error { + stateConf := &resource.StateChangeConf{ + Pending: []string{dynamodb.ContributorInsightsStatusEnabling}, + Target: []string{dynamodb.ContributorInsightsStatusEnabled}, + Timeout: timeout, + Refresh: statusContributorInsights(ctx, conn, tableName, indexName), + } + + _, err := stateConf.WaitForStateContext(ctx) + + return err +} + +func waitContributorInsightsDeleted(ctx context.Context, conn *dynamodb.DynamoDB, tableName, indexName string, timeout time.Duration) error { + stateConf := &resource.StateChangeConf{ + Pending: []string{dynamodb.ContributorInsightsStatusDisabling}, + Target: []string{}, + Timeout: timeout, + Refresh: statusContributorInsights(ctx, conn, tableName, indexName), + } + + _, err := stateConf.WaitForStateContext(ctx) + + return err +} diff --git a/website/docs/r/dynamodb_contributor_insights.html.markdown b/website/docs/r/dynamodb_contributor_insights.html.markdown new file mode 100644 index 00000000000..5a07ee1a666 --- /dev/null +++ b/website/docs/r/dynamodb_contributor_insights.html.markdown @@ -0,0 +1,38 @@ +--- +subcategory: "DynamoDB" +layout: "aws" +page_title: "AWS: aws_dynamodb_contributor_insights" +description: |- + Provides a DynamoDB contributor insights resource +--- + +# Resource: aws_dynamodb_contributor_insights + +Provides a DynamoDB contributor insights resource + +## Example Usage + +```terraform +resource "aws_dynamodb_contributor_insights" "test" { + table_name = "ExampleTableName" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `table_name` - (Required) The name of the table to enable contributor insights +* `index_name` - (Optional) The global secondary index name + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +## Import + +`aws_dynamodb_contributor_insights` can be imported using the format `name:table_name/index:index_name`, followed by the account number, e.g., + +``` +$ terraform import aws_dynamodb_contributor_insights.test name:ExampleTableName/index:ExampleIndexName/123456789012 +```