Skip to content

Instantly share code, notes, and snippets.

@Sodki
Last active July 12, 2020 11:42
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Sodki/95b04ee9f4f44ed81de23b0cff3a4685 to your computer and use it in GitHub Desktop.
Save Sodki/95b04ee9f4f44ed81de23b0cff3a4685 to your computer and use it in GitHub Desktop.
Infrastructure orchestration of multiple cloud accounts in a single step

Infrastructure orchestration of multiple cloud accounts in a single step

Too many accounts, are they all up to date?

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.

The authentication problem

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.

Terraform

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.

Ansible

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.

Pulumi

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

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.

Do we really want this?

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment