Create AWS Accounts with CloudFormation
September 10, 2018In a previous post, we covered how to use an AWS Custom Resource in a CloudFormation template to deploy a very basic Lambda function. To expand upon this ability, lets use this knowledge to deploy something more useful than a basic Lambda function. How about we use it to create an AWS account? To my knowledge, the only way to create a new AWS account is to use the CLI or manually through the console. How about we use a custom resource to deploy a new account for us in our AWS Organization? Once this ability is available in a CloudFormation template, we could even publish it in the AWS Service Catalog and give our users an account vending machine capability.
Create the Lambda Function
Just as we did in the previous post, we’ll create a Lambda function, zip it up and place it into our S3 bucket. My function is Python 2.7 and can be found below.
#Import modules import json, boto3, logging from botocore.vendored import requests #Define logging properties log = logging.getLogger() log.setLevel(logging.INFO) #Main Lambda function to be excecuted def lambda_handler(event, context): #Initialize the status of the function status="SUCCESS" responseData = {} client = boto3.client('organizations') #Read and log the input values acctName = event['ResourceProperties']['AccountName'] ouName = event['ResourceProperties']['OUName'] emailAddress = event['ResourceProperties']['Email'] log.info("Account name is: " + acctName) log.info("Organizational Unit name is: " + ouName) log.info("Email Address is: " + emailAddress) #create a new Organizational Unit orgResponse = client.create_organizational_unit( ParentId="ou-3hvv-jqwq89r0", #My Parent OU. Change for your environment Name=ouName ) log.info(orgResponse['OrganizationalUnit']['Id']) OUID=str(orgResponse['OrganizationalUnit']['Id']) #Create a new Account in the OU Just Created acctResponse = client.create_account( Email=emailAddress, AccountName=acctName ) #Check Account Status acctStatusID = acctResponse['CreateAccountStatus']['Id'] log.info(acctStatusID) while True: createStatus = client.describe_create_account_status( CreateAccountRequestId=acctStatusID ) log.info(createStatus['CreateAccountStatus']['State']) if str(createStatus['CreateAccountStatus']['State']) != 'IN_PROGRESS': newAccountId = str(createStatus['CreateAccountStatus']['AccountId']) break #Move Account to new OU moveResponse = client.move_account( AccountId=newAccountId, SourceParentId='r-3hvv', #My root OU. Change for your environment DestinationParentId=OUID ) #Set Return Data responseData = {"Message" : newAccountId} #If you need to return data use this json object #return the response back to the S3 URL to notify CloudFormation about the code being run response=respond(event,context,status,responseData,None) #Function returns the response from the S3 URL return { "Response" :response } def respond(event, context, responseStatus, responseData, physicalResourceId): #Build response payload required by CloudFormation responseBody = {} responseBody['Status'] = responseStatus responseBody['Reason'] = 'Details in: ' + context.log_stream_name responseBody['PhysicalResourceId'] = context.log_stream_name responseBody['StackId'] = event['StackId'] responseBody['RequestId'] = event['RequestId'] responseBody['LogicalResourceId'] = event['LogicalResourceId'] responseBody['Data'] = responseData #Convert json object to string and log it json_responseBody = json.dumps(responseBody) log.info("Response body: " + str(json_responseBody)) #Set response URL responseUrl = event['ResponseURL'] #Set headers for preparation for a PUT headers = { 'content-type' : '', 'content-length' : str(len(json_responseBody)) } #Return the response to the signed S3 URL try: response = requests.put(responseUrl, data=json_responseBody, headers=headers) log.info("Status code: " + str(response.reason)) status="SUCCESS" return status #Defind what happens if the PUT operation fails except Exception as e: log.error("send(..) failed executing requests.put(..): " + str(e)) status="FAILED" return status
As before, lets break down a few of the relevant sections of the code so you can see whats happening. To begin, lets look at the main lambda_handler. First we’ll initialize some of our variables and set our boto3 client to organizations so that we can create our accounts. After this, we’re going to set some variables in our Lambda function that will be passed in from our CloudFormation template (shown later in this post). After we set our variables, we’ll log them so that we can see what CloudFormation actually passed to our function.
#Main Lambda function to be excecuted def lambda_handler(event, context): #Initialize the status of the function status="SUCCESS" responseData = {} client = boto3.client('organizations') #Read and log the input values acctName = event['ResourceProperties']['AccountName'] ouName = event['ResourceProperties']['OUName'] emailAddress = event['ResourceProperties']['Email'] log.info("Account name is: " + acctName) log.info("Organizational Unit name is: " + ouName) log.info("Email Address is: " + emailAddress)
Next, we’ll use boto3 to create an Organizational Unit on the fly. We’ll pass in the name of this OU from our CloudFormation template. To do this we’ll use the create_organizational_unit method and we’ll need to pass in the parent OU and the name of our new OU. When we’re done, we’ll log the ID of this OU and I’m setting a variable with the ID of this OU as well for later on in the function.
#create a new Organizational Unit orgResponse = client.create_organizational_unit( ParentId="ou-3hvv-jqwq89r0", #My Parent OU. Change for your environment Name=ouName ) log.info(orgResponse['OrganizationalUnit']['Id']) OUID=str(orgResponse['OrganizationalUnit']['Id'])
Now that we’ve created an OU, lets create the account. Again, we’ll use boto3 to call the create_account method. We’ll pass in an email address to be used for the new account and an account name. Again, when this is done, we’ll log the response which is the account status. After this, we’ll initiate a loop to check on the status of the account while it’s being created. Account creation isn’t an immediate thing, so its good to check on it until its either Successful or Failed. The loop checks the status, logs it and waits until its no longer IN_PROGRESS.
#Create a new Account in the OU Just Created acctResponse = client.create_account( Email=emailAddress, AccountName=acctName ) #Check Account Status acctStatusID = acctResponse['CreateAccountStatus']['Id'] log.info(acctStatusID) while True: createStatus = client.describe_create_account_status( CreateAccountRequestId=acctStatusID ) log.info(createStatus['CreateAccountStatus']['State']) if str(createStatus['CreateAccountStatus']['State']) != 'IN_PROGRESS': newAccountId = str(createStatus['CreateAccountStatus']['AccountId']) break
With any luck, our account has been created and we’ve got one more thing to do. Lets move the new account, into the new OU we created. Again, we’ll use boto3, but this time with the move_account method. I’m passing in the new AccountId we stored from our new create_account method, and the OUID we stored from our create_organizational_unit. I’m also specifying my root OU which will be different in your case. Fill it in, or do a search to find it in the Lambda function.
#Move Account to new OU moveResponse = client.move_account( AccountId=newAccountId, SourceParentId='r-3hvv', #My root OU. Change for your environment DestinationParentId=OUID )
The account stuff is done, now we’re just setting some return data to be sent back to CloudFormation. The info I want sent back to CFn as an output is the accountID of our new account we created. After which we’ll send the data back to our signed S3 URL as we explained in the previous post about Custom Resorces.
#Set Return Data responseData = {"Message" : newAccountId} #If you need to return data use this json object #return the response back to the S3 URL to notify CloudFormation about the code being run response=respond(event,context,status,responseData,None) #Function returns the response from the S3 URL return { "Response" :response }
Create the CloudFormation Template
If you’re following from the previous post, the only changes to the CloudFormation template are the variables being passed back and forth. For that reason we won’t go into much detail here, but the full template I used is found below.
--- AWSTemplateFormatVersion: '2010-09-09' Description: Account Creation Stack Parameters: ModuleName: #Name of the Lambda Module Description: The name of the Python file Type: String Default: "create-account" S3Bucket: #S3 bucket in which to retrieve the python script with the Lambda handler Description: The name of the bucket that contains your packaged source Type: String Default: "hollow-acct" S3Key: #Name of the zip file Description: The name of the ZIP package Type: String Default: "create-account.zip" AccountName: #Account Name Description: Account Name To Be Created Type: String Default: "HollowTest1" OUName: #Organizational Unit Name Description: Organizational Unit Name To Be Created Type: String Default: "HollowTest1" Email: #Email Address Description: Email Address used for the Account Type: String Resources: CreateAccount: #Custom Resource Type: Custom::CreateAccount Properties: ServiceToken: Fn::GetAtt: - TestFunction #Reference to Function to be run - Arn #ARN of the function to be run AccountName: Ref: AccountName OUName: Ref: OUName Email: Ref: Email TestFunction: #Lambda Function Type: AWS::Lambda::Function Properties: Code: S3Bucket: Ref: S3Bucket S3Key: Ref: S3Key Handler: Fn::Join: - '' - - Ref: ModuleName - ".lambda_handler" Role: Fn::GetAtt: - LambdaExecutionRole - Arn Runtime: python2.7 Timeout: '30' LambdaExecutionRole: #IAM Role for Custom Resource Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: - lambda.amazonaws.com Action: - sts:AssumeRole Path: "/" Policies: - PolicyName: root PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - logs:CreateLogGroup - logs:CreateLogStream - logs:PutLogEvents Resource: arn:aws:logs:*:*:* - PolicyName: Acct PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - organizations:* Resource: "*" Outputs: #Return output from the Lambda Function Run Message: Description: Message returned from Lambda Value: Fn::GetAtt: - CreateAccount #Output from the Custom Resource - Message #Return property
Results
Now that the coding is done, we can deploy the CloudFormation template. I’ve chosen to do this through the command line but you could do it through the console as well. My command line execution is as follows:
aws cloudformation create-stack --stack-name theITHollowCreateAccount1 --template-body file://create-account-CFn.yml --capabilities CAPABILITY_IAM --parameters ParameterKey=AccountName,ParameterValue=theITHollowAcct1 ParameterKey=OUName,ParameterValue=theITHollowOU1 ParameterKey=Email,ParameterValue=aws-temp4@theithollow.com ParameterKey=ModuleName,ParameterValue=create-account ParameterKey=S3Bucket,ParameterValue=hollow-acct ParameterKey=S3Key,ParameterValue=create-account.zip
After a minute, you’ll see that the CFn stack has been deployed successfully, and that the output for the stack is the account number for the new AWS account.
If we open up the AWS Organizations console, we’ll see that the new account was created and the account number matches our output from the screenshot above.
As we look through the organizational units, we’ll see that a new OU was created and that our new account lives within that OU.
Summary
I hope that this post has shown you what kind of cool stuff you can do with a CloudFormation custom resource. Now think how neat this might be to put in AWS Service Catalog to deploy new accounts on demand. I will admit that this method does have some drawbacks, such as not being able to delete the stack and have the account deleted, but it is what it is.
hello sir my stack is getting stuck on event create account
Did you update your parentID? Any other logs you get about the create account step?
shazza,
You have to log into the created account and finish the creation process from inside the new account. Steps as follows;
– From the master account, make sure you’re logged in as a user with full administrator privileges.
– Go into the Organizations console, find the new account you just created, and copy the new account number
– In the upper right-hand corner of your console, you should see your username@account name, click on that, and then near the bottom click “Switch Role”
– In the Switch Role screen, enter the new account number, and the following role name; OrganizationAccountAccessRole. Give it an easy to remember alias if you’d like
– Click, switch role and you should now be in the new account.
– If you’re asked to submit credit card information, fill it all out, once you’re done you should be in the AWS console. You can either create a new user for this account, or just drop your cross account role.
– Once you drop your cross account role, go back to CloudFormation and the stack should be in a complete state.
This is brilliant! (he says as he throws away his already written attempts at creating a similar solution 😉
OK, not quite, I was envisioning something similar by combining it with Troposphere where some parts are straight out boto3 calls to the API (to create the organizations as well), create and run the CFN templates and shift back to boto3 when required.
That it could be done with a custom resource hadn’t even immediately occurred to me!
Now I wonder how far down the rabbit hole you can go: can you indeed create an organization with a custom resource similar to your approach? Would I want to?
Eric,
This post is great, thank you! I am attempting to run this stack, and its erroring out on me. In CloudFormation I get the following error;
Custom Resource failed to stabilize in expected time
If I look in my CloudWatch logs, I can see where it fails in the Lambda, here is the error;
‘AccountId’: KeyError
Traceback (most recent call last):
File “/var/task/createAccount.py”, line 52, in lambda_handler
newAccountId = str(createStatus[‘CreateAccountStatus’][‘AccountId’])
KeyError: ‘AccountId’
I also see some informational messages in there regarding resetting a dropped connection. Any ideas on what I’m doing wrong here?
Hi Kevin
have you got any solution for this issue
I did not, I honestly haven’t had time to come back to it. I’ll try and play around with it this weekend.
Figured it out, I kept using my primary email address, which is the address attached to my master-payer account. I wasn’t even really thinking about it until I stopped and slowed down for a second. LOL.