Many organisations use multiple accounts in their cloud provider as a way to enforce another layer separation between different sets of infrastructure. One example is to separate development and production environments, another one is to separate accounts of different customers. This usually ends up in multiple infrastructure runs to get to a desired state, which might lead to a feeling of disconnection between them.
It might not be an issue to you or it might be that you use different tools that glue all of it together. For all others, let's dive deeper into specific ways of achieving this with AWS and some well-known orchestration tools: Terraform, Ansible and CloudFormation. The same pattern can be applied to other tools and cloud providers, even mixing them.
If you have multiple accounts, then you must have a mechanism to authenticate against all of them. Tools deal with this in different ways, which we'll cover further on.
On AWS the two most common authentication patterns are having an IAM user per account, which doesn't scale very well, or use a hub-spoke model in which the user will authenticate against a landing zone account and then assume a role within a different account.
So far the easiest way to deal with this is to use AWS Control Tower when setting up a new AWS Organization. Not only it takes care of provisioning sub-accounts in an integrated way, but you can then manage all accesses out of the box via AWS SSO, which itself can integrate with other SSO systems like Okta, G Suite or Azure AD.
For simplicity sake we'll focus on the latter in the examples below and assume that you have configured your environment to be able to assume account roles via the landing zone account. This also simplifies CI/CD, since there's a single set of credentials to deal with. Change the assumed role names as you see fit.
Let's say we have two different AWS accounts that we want to keep the same. For
that purpose we'll create a module named provision_account
that creates an S3 bucket.
Since S3 bucket names are global, we'll append the account
ID to the name of the bucket.
data aws_caller_identity current {}
resource aws_s3_bucket bucket {
bucket = "example-bucket-${data.aws_caller_identity.current.account_id}"
}
On the main Terraform code we'll define two providers, one for each account we want to manage:
provider aws {
alias = "account1"
assume_role {
role_arn = "<assumed role on account 1>"
}
}
provider aws {
alias = "account2"
assume_role {
role_arn = "<assumed role on account 2>"
}
}
module provision_account {
source = "<module source>"
providers = {
aws = aws.account1
}
}
module provision_account {
source = "<module source>"
providers = {
aws = aws.account2
}
}
When you run terraform apply
this will create one S3 bucket on each account as
expected. terraform plan
will show you all the changes in all the accounts in
a single step.
Not all accounts need to be the same. You can have different versions of the same module, different modules for different accounts or even not have any modules at all and simply create the target resources.
A consequence of having multiple accounts managed this way is that you end up with a single state file. It's up to you to decide if this is a good thing or a bad thing, depending on your Terraform experience and the size of your team.
Continuing with the goal of creating two S3 buckets on two AWS accounts, let's
first create a role named provision_account
with the following tasks:
- sts_assume_role:
role_arn: "{{ role_arn }}"
role_session_name: "{{ role_session_name }}"
register: assumed_role
check_mode: no
changed_when: no
- aws_caller_info:
aws_access_key: "{{ assumed_role.sts_creds.access_key }}"
aws_secret_key: "{{ assumed_role.sts_creds.secret_key }}"
security_token: "{{ assumed_role.sts_creds.session_token }}"
register: caller_info
- s3_bucket:
name: "example-bucket-${caller_info.account}"
aws_access_key: "{{ assumed_role.sts_creds.access_key }}"
aws_secret_key: "{{ assumed_role.sts_creds.secret_key }}"
security_token: "{{ assumed_role.sts_creds.session_token }}"
The playbook can then contain the following:
- hosts: localhost
tasks:
- include_role:
name: aws_account
vars:
role_arn: "<assumed role on account 1>"
role_session_name: "<role session name>"
- include_role:
name: aws_account
vars:
role_arn: "<assumed role on account 1>"
role_session_name: "<role session name>"
Of course there are many different ways to write this in Ansible, it's just an example.
Unsurprisingly, Pulumi follows the same pattern as Terraform. We first define custom providers, then we reference those providers when creating resources. In this example we'll be using Python:
import pulumi, pulumi_aws
account1 = pulumi_aws.Provider("account1",
assume_role={
"role_arn": "<assumed role on account 1>"},
region=pulumi_aws.get_region().name)
account2 = pulumi_aws.Provider("account2",
assume_role={
"role_arn": "<assumed role on account 2>"},
region=pulumi_aws.get_region().name)
for provider in account1, account2:
pulumi_aws.s3.Bucket("example-bucket",
opts=pulumi.ResourceOptions(provider=provider))
CloudFormation stacks are applied to a single account, but since February 2020 AWS has the capability to apply a CloudFormation template to multiple accounts on your AWS Organization via StackSets.
In a hub-spoke account model you create a StackSet, define your CloudFormation template and select which accounts it should be deployed to. CloudFormation will then create stacks on each of the accounts.
In order for this to work properly, you must have the correct roles created on the target accounts, so that CloudFormation can assume them. If using AWS Control Tower then this is already done by default, since it's actually what AWS Control Tower itself uses to provision accounts.
Drift detection is possible on a StackSet, which gives you a unified view of the state of each account. This uses the same drift detection mechanism of CloudFormation stacks and it takes a while to run. There are ways to make it run periodically, but might require a Lambda function to trigger it, which seems an unfortunate afterthought. There is also no way of getting outputs of target stacks from the hub account, making cross account dependencies impossible.
Still, even with all these caveats, if you're a pure CloudFormation user then StackSets can prove to be very useful.
This approach has both pros and cons. There's a simplicity in having a single provisioning step, but the risk of failure and the consequences thereof are also greater. The tools used here also have different capabilities that might mitigate or exacerbate failures.
In the end this is just another pattern to enrich your toolkit.