Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New Resource: aws_organizations_account #3524

Merged
merged 13 commits into from
Apr 6, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions aws/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,7 @@ func Provider() terraform.ResourceProvider {
"aws_opsworks_permission": resourceAwsOpsworksPermission(),
"aws_opsworks_rds_db_instance": resourceAwsOpsworksRdsDbInstance(),
"aws_organizations_organization": resourceAwsOrganizationsOrganization(),
"aws_organizations_account": resourceAwsOrganizationsAccount(),
"aws_placement_group": resourceAwsPlacementGroup(),
"aws_proxy_protocol_policy": resourceAwsProxyProtocolPolicy(),
"aws_rds_cluster": resourceAwsRDSCluster(),
Expand Down
243 changes: 243 additions & 0 deletions aws/resource_aws_organizations_account.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
package aws

import (
"fmt"
"log"
"regexp"
"time"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/organizations"
"github.com/hashicorp/terraform/helper/resource"
"github.com/hashicorp/terraform/helper/schema"
"github.com/hashicorp/terraform/helper/validation"
)

func resourceAwsOrganizationsAccount() *schema.Resource {
return &schema.Resource{
Create: resourceAwsOrganizationsAccountCreate,
Read: resourceAwsOrganizationsAccountRead,
Delete: resourceAwsOrganizationsAccountDelete,
Importer: &schema.ResourceImporter{
State: schema.ImportStatePassthrough,
},

Schema: map[string]*schema.Schema{
"arn": {
Type: schema.TypeString,
Computed: true,
},
"joined_method": {
Type: schema.TypeString,
Computed: true,
},
"joined_timestamp": {
Type: schema.TypeString,
Computed: true,
},
"status": {
Type: schema.TypeString,
Computed: true,
},
"name": {
ForceNew: true,
Type: schema.TypeString,
Required: true,
ValidateFunc: validation.StringLenBetween(1, 50),
},
"email": {
ForceNew: true,
Type: schema.TypeString,
Required: true,
ValidateFunc: validateAwsOrganizationsAccountEmail,
},
"iam_user_access_to_billing": {
ForceNew: true,
Type: schema.TypeString,
Optional: true,
ValidateFunc: validation.StringInSlice([]string{organizations.IAMUserAccessToBillingAllow, organizations.IAMUserAccessToBillingDeny}, true),
},
"role_name": {
ForceNew: true,
Type: schema.TypeString,
Optional: true,
ValidateFunc: validateAwsOrganizationsAccountRoleName,
},
},
}
}

func resourceAwsOrganizationsAccountCreate(d *schema.ResourceData, meta interface{}) error {
conn := meta.(*AWSClient).organizationsconn

// Create the account
createOpts := &organizations.CreateAccountInput{
AccountName: aws.String(d.Get("name").(string)),
Email: aws.String(d.Get("email").(string)),
}
if role, ok := d.GetOk("role_name"); ok {
createOpts.RoleName = aws.String(role.(string))
}

if iam_user, ok := d.GetOk("iam_user_access_to_billing"); ok {
createOpts.IamUserAccessToBilling = aws.String(iam_user.(string))
}

log.Printf("[DEBUG] Account create config: %#v", createOpts)

var err error
var resp *organizations.CreateAccountOutput
err = resource.Retry(4*time.Minute, func() *resource.RetryError {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of curiosity, how was 4 minutes decided here?

Copy link
Contributor Author

@asedge asedge Feb 27, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't remember 100% because this code is nearly a year old. After looking at it for a little bit I think the issue was that there's no API action to check the status of CreateOrganization. I didn't realize this could be a problem until I wrote this resource, because I was creating an organization and immediately adding an account, so I put the poll here. Allowing organizations.ErrCodeFinalizingOrganizationException as a RetryableError seems to back that up.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome information! If that's the case, maybe we (more like I 😄 ) should setup the organization resource to wait until the organization is finalized before returning that its done on creation. I'll make a note to check that myself.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you create an issue and assign it to me, I will get it done.

resp, err = conn.CreateAccount(createOpts)

if err != nil {
if isAWSErr(err, organizations.ErrCodeFinalizingOrganizationException, "") {
log.Printf("[DEBUG] Trying to create account again: %q", err.Error())
return resource.RetryableError(err)
}

return resource.NonRetryableError(err)
}

return nil
})

if err != nil {
return fmt.Errorf("Error creating account: %s", err)
}
log.Printf("[DEBUG] Account create response: %#v", resp)

requestId := *resp.CreateAccountStatus.Id

// Wait for the account to become available
log.Printf("[DEBUG] Waiting for account request (%s) to succeed", requestId)

stateConf := &resource.StateChangeConf{
Pending: []string{organizations.CreateAccountStateInProgress},
Target: []string{organizations.CreateAccountStateSucceeded},
Refresh: resourceAwsOrganizationsAccountStateRefreshFunc(conn, requestId),
PollInterval: 10 * time.Second,
Timeout: 5 * time.Minute,
}
stateResp, stateErr := stateConf.WaitForState()
if stateErr != nil {
return fmt.Errorf(
"Error waiting for account request (%s) to become available: %s",
requestId, stateErr)
}

// Store the ID
accountId := stateResp.(*organizations.CreateAccountStatus).AccountId
d.SetId(*accountId)

return resourceAwsOrganizationsAccountRead(d, meta)
}

func resourceAwsOrganizationsAccountRead(d *schema.ResourceData, meta interface{}) error {
conn := meta.(*AWSClient).organizationsconn
describeOpts := &organizations.DescribeAccountInput{
AccountId: aws.String(d.Id()),
}
resp, err := conn.DescribeAccount(describeOpts)
if err != nil {
if isAWSErr(err, organizations.ErrCodeAccountNotFoundException, "") {
log.Printf("[WARN] Account does not exist, removing from state: %s", d.Id())
d.SetId("")
return nil
}
return err
}

account := resp.Account
if account == nil {
log.Printf("[WARN] Account does not exist, removing from state: %s", d.Id())
d.SetId("")
return nil
}

d.Set("arn", account.Arn)
d.Set("email", account.Email)
d.Set("joined_method", account.JoinedMethod)
d.Set("joined_timestamp", account.JoinedTimestamp)
d.Set("name", account.Name)
d.Set("status", account.Status)
return nil
}

func resourceAwsOrganizationsAccountDelete(d *schema.ResourceData, meta interface{}) error {
conn := meta.(*AWSClient).organizationsconn

input := &organizations.RemoveAccountFromOrganizationInput{
AccountId: aws.String(d.Id()),
}
log.Printf("[DEBUG] Removing AWS account from organization: %s", input)
_, err := conn.RemoveAccountFromOrganization(input)
if err != nil {
if isAWSErr(err, organizations.ErrCodeAccountNotFoundException, "") {
return nil
}
return err
}
return nil
}

// resourceAwsOrganizationsAccountStateRefreshFunc returns a resource.StateRefreshFunc
// that is used to watch a CreateAccount request
func resourceAwsOrganizationsAccountStateRefreshFunc(conn *organizations.Organizations, id string) resource.StateRefreshFunc {
return func() (interface{}, string, error) {
opts := &organizations.DescribeCreateAccountStatusInput{
CreateAccountRequestId: aws.String(id),
}
resp, err := conn.DescribeCreateAccountStatus(opts)
if err != nil {
if isAWSErr(err, organizations.ErrCodeCreateAccountStatusNotFoundException, "") {
resp = nil
} else {
log.Printf("Error on OrganizationAccountStateRefresh: %s", err)
return nil, "", err
}
}

if resp == nil {
// Sometimes AWS just has consistency issues and doesn't see
// our account yet. Return an empty state.
return nil, "", nil
}

accountStatus := resp.CreateAccountStatus
if *accountStatus.State == organizations.CreateAccountStateFailed {
return nil, *accountStatus.State, fmt.Errorf(*accountStatus.FailureReason)
}
return accountStatus, *accountStatus.State, nil
}
}

func validateAwsOrganizationsAccountEmail(v interface{}, k string) (ws []string, errors []error) {
value := v.(string)
if !regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`).MatchString(value) {
errors = append(errors, fmt.Errorf(
"%q must be a valid email address", value))
}

if len(value) < 6 {
errors = append(errors, fmt.Errorf(
"%q cannot be less than 6 characters", value))
}

if len(value) > 64 {
errors = append(errors, fmt.Errorf(
"%q cannot be greater than 64 characters", value))
}

return
}

func validateAwsOrganizationsAccountRoleName(v interface{}, k string) (ws []string, errors []error) {
value := v.(string)
if !regexp.MustCompile(`^[\w+=,.@-]{1,64}$`).MatchString(value) {
errors = append(errors, fmt.Errorf(
"%q must consist of uppercase letters, lowercase letters, digits with no spaces, and any of the following characters: =,.@-", value))
}

return
}
118 changes: 118 additions & 0 deletions aws/resource_aws_organizations_account_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package aws

import (
"fmt"
"os"
"testing"

"github.com/aws/aws-sdk-go/service/organizations"
"github.com/hashicorp/terraform/helper/acctest"
"github.com/hashicorp/terraform/helper/resource"
"github.com/hashicorp/terraform/terraform"
)

func testAccAwsOrganizationsAccount_basic(t *testing.T) {
var account organizations.Account

orgsEmailDomain, ok := os.LookupEnv("TEST_AWS_ORGANIZATION_ACCOUNT_EMAIL_DOMAIN")

if !ok {
t.Skip("'TEST_AWS_ORGANIZATION_ACCOUNT_EMAIL_DOMAIN' not set, skipping test.")
}

rInt := acctest.RandInt()
name := fmt.Sprintf("tf_acctest_%s", rInt)
email := fmt.Sprintf("tf-acctest+%d@%s", rInt, orgsEmailDomain)

resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
Providers: testAccProviders,
CheckDestroy: testAccCheckAwsOrganizationsAccountDestroy,
Steps: []resource.TestStep{
{
Config: testAccAwsOrganizationsAccountConfig(name, email),
Check: resource.ComposeTestCheckFunc(
testAccCheckAwsOrganizationsAccountExists("aws_organizations_account.test", &account),
resource.TestCheckResourceAttrSet("aws_organizations_account.test", "arn"),
resource.TestCheckResourceAttrSet("aws_organizations_account.test", "joined_method"),
resource.TestCheckResourceAttrSet("aws_organizations_account.test", "joined_timestamp"),
resource.TestCheckResourceAttr("aws_organizations_account.test", "name", name),
resource.TestCheckResourceAttr("aws_organizations_account.test", "email", email),
resource.TestCheckResourceAttrSet("aws_organizations_account.test", "status"),
),
},
{
ResourceName: "aws_organizations_account.test",
ImportState: true,
ImportStateVerify: true,
},
},
})
}

func testAccCheckAwsOrganizationsAccountDestroy(s *terraform.State) error {
conn := testAccProvider.Meta().(*AWSClient).organizationsconn

for _, rs := range s.RootModule().Resources {
if rs.Type != "aws_organizations_account" {
continue
}

params := &organizations.DescribeAccountInput{
AccountId: &rs.Primary.ID,
}

resp, err := conn.DescribeAccount(params)

if err != nil {
if isAWSErr(err, organizations.ErrCodeAccountNotFoundException, "") {
return nil
}
return err
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might need to check for AccountNotFoundException as the SDK documentation notes:

We can't find an AWS account with the AccountId that you specified, or the
account whose credentials you used to make this request is not a member of
an organization.

I believe in our testing at this point the testing account will be removed from the organization again, so this would be the case:

if isAWSErr(err, organizations.ErrCodeAccountNotFoundException, "") {
  return nil
}

}

if resp == nil && resp.Account != nil {
return fmt.Errorf("Bad: Account still exists: %q", rs.Primary.ID)
}
}

return nil

}

func testAccCheckAwsOrganizationsAccountExists(n string, a *organizations.Account) resource.TestCheckFunc {
return func(s *terraform.State) error {
rs, ok := s.RootModule().Resources[n]
if !ok {
return fmt.Errorf("Not found: %s", n)
}

conn := testAccProvider.Meta().(*AWSClient).organizationsconn
params := &organizations.DescribeAccountInput{
AccountId: &rs.Primary.ID,
}

resp, err := conn.DescribeAccount(params)

if err != nil {
return err
}

if resp == nil || resp.Account == nil {
return fmt.Errorf("Account %q does not exist", rs.Primary.ID)
}

a = resp.Account

return nil
}
}

func testAccAwsOrganizationsAccountConfig(name, email string) string {
return fmt.Sprintf(`
resource "aws_organizations_account" "test" {
name = "%s"
email = "%s"
}
`, name, email)
}
3 changes: 3 additions & 0 deletions aws/resource_aws_organizations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ func TestAccAWSOrganizations(t *testing.T) {
"importBasic": testAccAwsOrganizationsOrganization_importBasic,
"consolidatedBilling": testAccAwsOrganizationsOrganization_consolidatedBilling,
},
"Account": {
"basic": testAccAwsOrganizationsAccount_basic,
},
}

for group, m := range testCases {
Expand Down
3 changes: 3 additions & 0 deletions website/aws.erb
Original file line number Diff line number Diff line change
Expand Up @@ -1374,6 +1374,9 @@
<a href="#">Organizations Resources</a>
<ul class="nav nav-visible">

<li<%= sidebar_current("docs-aws-resource-organizations-account") %>>
<a href="/docs/providers/aws/r/organizations_account.html">aws_organizations_account</a>
</li>
<li<%= sidebar_current("docs-aws-resource-organizations-organization") %>>
<a href="/docs/providers/aws/r/organizations_organization.html">aws_organizations_organization</a>
</li>
Expand Down
Loading