diff --git a/.changelog/35739.txt b/.changelog/35739.txt new file mode 100644 index 000000000000..e8623c1bdd73 --- /dev/null +++ b/.changelog/35739.txt @@ -0,0 +1,3 @@ +```release-note:new-resource +aws_account_region +``` \ No newline at end of file diff --git a/internal/acctest/acctest.go b/internal/acctest/acctest.go index 3bd0761314cb..85e0abdebae4 100644 --- a/internal/acctest/acctest.go +++ b/internal/acctest/acctest.go @@ -1084,7 +1084,7 @@ func PreCheckOrganizationMemberAccount(ctx context.Context, t *testing.T) { func PreCheckRegionOptIn(ctx context.Context, t *testing.T, region string) { t.Helper() - output, err := tfaccount.FindRegionOptInStatus(ctx, Provider.Meta().(*conns.AWSClient).AccountClient(ctx), "", region) + output, err := tfaccount.FindRegionOptStatus(ctx, Provider.Meta().(*conns.AWSClient).AccountClient(ctx), "", region) if err != nil { t.Fatalf("reading Region (%s) opt-in status: %s", region, err) diff --git a/internal/service/account/account_test.go b/internal/service/account/account_test.go index 0f6b61663031..83a48763138f 100644 --- a/internal/service/account/account_test.go +++ b/internal/service/account/account_test.go @@ -21,6 +21,10 @@ func TestAccAccount_serial(t *testing.T) { "PrimaryContact": { "basic": testAccPrimaryContact_basic, }, + "Region": { + "basic": testAccRegion_basic, + "AccountID": testAccRegion_accountID, + }, } acctest.RunSerialTests2Levels(t, testCases, 0) diff --git a/internal/service/account/exports.go b/internal/service/account/exports.go new file mode 100644 index 000000000000..a3800dbeb8c9 --- /dev/null +++ b/internal/service/account/exports.go @@ -0,0 +1,9 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package account + +// Exports for use in other modules. +var ( + FindRegionOptStatus = findRegionOptStatus +) diff --git a/internal/service/account/region.go b/internal/service/account/region.go index 13fd0f118549..90c1d4e6dcfd 100644 --- a/internal/service/account/region.go +++ b/internal/service/account/region.go @@ -5,13 +5,163 @@ package account import ( "context" + "strings" + "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/account" + "github.com/aws/aws-sdk-go-v2/service/account/types" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-provider-aws/internal/conns" + "github.com/hashicorp/terraform-provider-aws/internal/enum" + "github.com/hashicorp/terraform-provider-aws/internal/errs" + "github.com/hashicorp/terraform-provider-aws/internal/errs/sdkdiag" + "github.com/hashicorp/terraform-provider-aws/internal/flex" "github.com/hashicorp/terraform-provider-aws/internal/tfresource" + "github.com/hashicorp/terraform-provider-aws/internal/verify" ) -func FindRegionOptInStatus(ctx context.Context, conn *account.Client, accountID, region string) (*account.GetRegionOptStatusOutput, error) { +// @SDKResource("aws_account_region", name="Region") +func resourceRegion() *schema.Resource { + return &schema.Resource{ + CreateWithoutTimeout: resourceRegionUpdate, + ReadWithoutTimeout: resourceRegionRead, + UpdateWithoutTimeout: resourceRegionUpdate, + DeleteWithoutTimeout: schema.NoopContext, + + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + "account_id": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ValidateFunc: verify.ValidAccountID, + }, + "enabled": { + Type: schema.TypeBool, + Required: true, + }, + "opt_status": { + Type: schema.TypeString, + Computed: true, + }, + "region_name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + }, + + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(60 * time.Minute), + Update: schema.DefaultTimeout(60 * time.Minute), + }, + } +} + +const ( + regionResourceIDPartCount = 2 +) + +func resourceRegionUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + var diags diag.Diagnostics + conn := meta.(*conns.AWSClient).AccountClient(ctx) + + var id string + region := d.Get("region_name").(string) + accountID := "" + if v, ok := d.GetOk("account_id"); ok { + accountID = v.(string) + id = errs.Must(flex.FlattenResourceId([]string{accountID, region}, regionResourceIDPartCount, false)) + } else { + id = region + } + + timeout := d.Timeout(schema.TimeoutCreate) + if !d.IsNewResource() { + timeout = d.Timeout(schema.TimeoutUpdate) + } + + if v := d.Get("enabled").(bool); v { + input := &account.EnableRegionInput{ + RegionName: aws.String(region), + } + if accountID != "" { + input.AccountId = aws.String(accountID) + } + + _, err := conn.EnableRegion(ctx, input) + + if err != nil { + return sdkdiag.AppendErrorf(diags, "enabling Account Region (%s): %s", id, err) + } + + if _, err := waitRegionEnabled(ctx, conn, accountID, region, timeout); err != nil { + return sdkdiag.AppendErrorf(diags, "waiting for Account Region (%s) enable: %s", d.Id(), err) + } + } else { + input := &account.DisableRegionInput{ + RegionName: aws.String(region), + } + if accountID != "" { + input.AccountId = aws.String(accountID) + } + + _, err := conn.DisableRegion(ctx, input) + + if err != nil { + return sdkdiag.AppendErrorf(diags, "enabling Account Region (%s): %s", id, err) + } + + if _, err := waitRegionDisabled(ctx, conn, accountID, region, timeout); err != nil { + return sdkdiag.AppendErrorf(diags, "waiting for Account Region (%s) disable: %s", d.Id(), err) + } + } + + if d.IsNewResource() { + d.SetId(id) + } + + return append(diags, resourceRegionRead(ctx, d, meta)...) +} + +func resourceRegionRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + var diags diag.Diagnostics + conn := meta.(*conns.AWSClient).AccountClient(ctx) + + var accountID, region string + if strings.Contains(d.Id(), flex.ResourceIdSeparator) { + parts, err := flex.ExpandResourceId(d.Id(), regionResourceIDPartCount, false) + if err != nil { + return sdkdiag.AppendFromErr(diags, err) + } + + accountID = parts[0] + region = parts[1] + } else { + region = d.Id() + } + + output, err := findRegionOptStatus(ctx, conn, accountID, region) + + if err != nil { + return sdkdiag.AppendErrorf(diags, "reading Account Region (%s): %s", d.Id(), err) + } + + d.Set("account_id", accountID) + d.Set("enabled", output.RegionOptStatus == types.RegionOptStatusEnabled || output.RegionOptStatus == types.RegionOptStatusEnabledByDefault) + d.Set("opt_status", string(output.RegionOptStatus)) + d.Set("region_name", output.RegionName) + + return diags +} + +func findRegionOptStatus(ctx context.Context, conn *account.Client, accountID, region string) (*account.GetRegionOptStatusOutput, error) { input := &account.GetRegionOptStatusInput{ RegionName: aws.String(region), } @@ -31,3 +181,57 @@ func FindRegionOptInStatus(ctx context.Context, conn *account.Client, accountID, return output, nil } + +func statusRegionOptStatus(ctx context.Context, conn *account.Client, accountID, region string) retry.StateRefreshFunc { + return func() (interface{}, string, error) { + output, err := findRegionOptStatus(ctx, conn, accountID, region) + + if tfresource.NotFound(err) { + return nil, "", nil + } + + if err != nil { + return nil, "", err + } + + return output, string(output.RegionOptStatus), nil + } +} + +func waitRegionEnabled(ctx context.Context, conn *account.Client, accountID, region string, timeout time.Duration) (*account.GetRegionOptStatusOutput, error) { + stateConf := &retry.StateChangeConf{ + Pending: enum.Slice(types.RegionOptStatusEnabling), + Target: enum.Slice(types.RegionOptStatusEnabled), + Refresh: statusRegionOptStatus(ctx, conn, accountID, region), + Timeout: timeout, + Delay: 1 * time.Minute, + PollInterval: 30 * time.Second, + } + + outputRaw, err := stateConf.WaitForStateContext(ctx) + + if output, ok := outputRaw.(*account.GetRegionOptStatusOutput); ok { + return output, err + } + + return nil, err +} + +func waitRegionDisabled(ctx context.Context, conn *account.Client, accountID, region string, timeout time.Duration) (*account.GetRegionOptStatusOutput, error) { + stateConf := &retry.StateChangeConf{ + Pending: enum.Slice(types.RegionOptStatusDisabling), + Target: enum.Slice(types.RegionOptStatusDisabled), + Refresh: statusRegionOptStatus(ctx, conn, accountID, region), + Timeout: timeout, + Delay: 1 * time.Minute, + PollInterval: 30 * time.Second, + } + + outputRaw, err := stateConf.WaitForStateContext(ctx) + + if output, ok := outputRaw.(*account.GetRegionOptStatusOutput); ok { + return output, err + } + + return nil, err +} diff --git a/internal/service/account/region_test.go b/internal/service/account/region_test.go new file mode 100644 index 000000000000..b134c064b617 --- /dev/null +++ b/internal/service/account/region_test.go @@ -0,0 +1,145 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package account_test + +import ( + "context" + "fmt" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/account/types" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-provider-aws/internal/acctest" + "github.com/hashicorp/terraform-provider-aws/internal/conns" + tfaccount "github.com/hashicorp/terraform-provider-aws/internal/service/account" + "github.com/hashicorp/terraform-provider-aws/names" +) + +func testAccRegion_basic(t *testing.T) { + ctx := acctest.Context(t) + resourceName := "aws_account_region.test" + regionName := names.APSoutheast3RegionID + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + testAccPreCheckRegionDisabled(ctx, t, regionName) + }, + ErrorCheck: acctest.ErrorCheck(t, names.AccountServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: acctest.CheckDestroyNoop, + Steps: []resource.TestStep{ + { + PreConfig: func() {}, + Config: testAccRegionConfig_basic(regionName, true), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "account_id", ""), + resource.TestCheckResourceAttr(resourceName, "enabled", "true"), + resource.TestCheckResourceAttr(resourceName, "opt_status", "ENABLED"), + resource.TestCheckResourceAttr(resourceName, "region_name", regionName), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccRegionConfig_basic(regionName, false), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "account_id", ""), + resource.TestCheckResourceAttr(resourceName, "enabled", "false"), + resource.TestCheckResourceAttr(resourceName, "opt_status", "DISABLED"), + resource.TestCheckResourceAttr(resourceName, "region_name", regionName), + ), + }, + }, + }) +} + +func testAccRegion_accountID(t *testing.T) { // nosemgrep:ci.account-in-func-name + ctx := acctest.Context(t) + resourceName := "aws_account_region.test" + regionName := names.APSoutheast3RegionID + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + acctest.PreCheckAlternateAccount(t) + acctest.PreCheckOrganizationManagementAccount(ctx, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.AccountServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5FactoriesAlternate(ctx, t), + CheckDestroy: acctest.CheckDestroyNoop, + Steps: []resource.TestStep{ + { + PreConfig: func() {}, + Config: testAccRegionConfig_organization(regionName, true), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "account_id"), + resource.TestCheckResourceAttr(resourceName, "enabled", "true"), + resource.TestCheckResourceAttr(resourceName, "opt_status", "ENABLED"), + resource.TestCheckResourceAttr(resourceName, "region_name", regionName), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccRegionConfig_organization(regionName, false), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "account_id"), + resource.TestCheckResourceAttr(resourceName, "enabled", "false"), + resource.TestCheckResourceAttr(resourceName, "opt_status", "DISABLED"), + resource.TestCheckResourceAttr(resourceName, "region_name", regionName), + ), + }, + }, + }) +} + +func testAccPreCheckRegionDisabled(ctx context.Context, t *testing.T, region string) { + t.Helper() + + conn := acctest.Provider.Meta().(*conns.AWSClient).AccountClient(ctx) + + output, err := tfaccount.FindRegionOptStatus(ctx, conn, "", region) + + if acctest.PreCheckSkipError(err) { + t.Skipf("skipping acceptance testing: %s", err) + } + + if err != nil { + t.Fatalf("unexpected PreCheck error: %s", err) + } + + if status := output.RegionOptStatus; status != types.RegionOptStatusDisabled { + t.Skipf("unexpected status (%s): %s", region, status) + } +} + +func testAccRegionConfig_basic(region string, enabled bool) string { + return fmt.Sprintf(` +resource "aws_account_region" "test" { + region_name = %[1]q + enabled = %[2]t +} +`, region, enabled) +} + +func testAccRegionConfig_organization(region string, enabled bool) string { + return acctest.ConfigCompose(acctest.ConfigAlternateAccountProvider(), fmt.Sprintf(` +data "aws_caller_identity" "test" { + provider = "awsalternate" +} + +resource "aws_account_region" "test" { + account_id = data.aws_caller_identity.test.account_id + region_name = %[1]q + enabled = %[2]t +} +`, region, enabled)) +} diff --git a/internal/service/account/service_package_gen.go b/internal/service/account/service_package_gen.go index 98cf66e2aca5..887fd0899e16 100644 --- a/internal/service/account/service_package_gen.go +++ b/internal/service/account/service_package_gen.go @@ -36,6 +36,11 @@ func (p *servicePackage) SDKResources(ctx context.Context) []*types.ServicePacka Factory: resourcePrimaryContact, TypeName: "aws_account_primary_contact", }, + { + Factory: resourceRegion, + TypeName: "aws_account_region", + Name: "Region", + }, } } diff --git a/website/docs/r/account_region.markdown b/website/docs/r/account_region.markdown new file mode 100644 index 000000000000..24152c4e3920 --- /dev/null +++ b/website/docs/r/account_region.markdown @@ -0,0 +1,58 @@ +--- +subcategory: "Account Management" +layout: "aws" +page_title: "AWS: aws_account_region" +description: |- + Enable (Opt-In) or Disable (Opt-Out) a particular Region for an AWS account +--- + +# Resource: aws_account_region + +Enable (Opt-In) or Disable (Opt-Out) a particular Region for an AWS account. + +## Example Usage + +```terraform +resource "aws_account_region" "example" { + region_name = "ap-southeast-3" + enabled = true +} +``` + +## Argument Reference + +This resource supports the following arguments: + +* `account_id` - (Optional) The ID of the target account when managing member accounts. Will manage current user's account by default if omitted. To use this parameter, the caller must be an identity in the organization's management account or a delegated administrator account. The specified account ID must also be a member account in the same organization. The organization must have all features enabled, and the organization must have trusted access enabled for the Account Management service, and optionally a delegated admin account assigned. +* `enabled` - (Required) Whether the region is enabled. +* `region_name` - (Required) The region name to manage. + +## Attribute Reference + +This resource exports the following attributes in addition to the arguments above: + +* `opt_status` - The region opt status. + +## Timeouts + +[Configuration options](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts): + +* `create` - (Default `60m`) +* `update` - (Default `60m`) + +## Import + +In Terraform v1.5.0 and later, use an [`import` block](https://developer.hashicorp.com/terraform/language/import) to import the account region using `region_name` or a comma separated `account_id` and `region_name`. For example: + +```terraform +import { + to = aws_account_region.example + id = "ap-southeast-3" +} +``` + +Using `terraform import`. For example: + +```console +% terraform import aws_account_region.example ap-southeast-3 +```