diff --git a/aws/provider.go b/aws/provider.go index 3f328c6761b9..71561459e1be 100644 --- a/aws/provider.go +++ b/aws/provider.go @@ -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(), diff --git a/aws/resource_aws_organizations_account.go b/aws/resource_aws_organizations_account.go new file mode 100644 index 000000000000..99bd7dae8fc4 --- /dev/null +++ b/aws/resource_aws_organizations_account.go @@ -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 { + 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 +} diff --git a/aws/resource_aws_organizations_account_test.go b/aws/resource_aws_organizations_account_test.go new file mode 100644 index 000000000000..b7020c40e0e0 --- /dev/null +++ b/aws/resource_aws_organizations_account_test.go @@ -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 + } + + 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) +} diff --git a/aws/resource_aws_organizations_test.go b/aws/resource_aws_organizations_test.go index 33fef61b690f..5804aeb009f3 100644 --- a/aws/resource_aws_organizations_test.go +++ b/aws/resource_aws_organizations_test.go @@ -11,6 +11,9 @@ func TestAccAWSOrganizations(t *testing.T) { "importBasic": testAccAwsOrganizationsOrganization_importBasic, "consolidatedBilling": testAccAwsOrganizationsOrganization_consolidatedBilling, }, + "Account": { + "basic": testAccAwsOrganizationsAccount_basic, + }, } for group, m := range testCases { diff --git a/website/aws.erb b/website/aws.erb index 235f8f196c12..a3bc388f8e51 100644 --- a/website/aws.erb +++ b/website/aws.erb @@ -1374,6 +1374,9 @@ Organizations Resources