In the last post, we bootstrapped our first Infrastructure as Code for our AWS account. The deployment of that IaC was still done manually however. In this post, we’ll look at how to set up continuous deployment (CD) of our IaC using Github Actions (GHA).
Refining our Access Control
Before we can fully automate things to let GHA apply our IaC changes, we need to make some changes and additions to our AWS access control resources that we have so far.
Creating an IAM role
The first step will to be to create an IAM role specifically for managing infrastructure. It’s often considered a best practice to create IAM roles with the permissions necessary for certain functions, and then have other IAM entities (like the user we previously set up for ourselves) assume these roles in order to take actions, as opposed to directly attaching permissions to them.
Here are the resources for our IAM role:
import * as aws from '@pulumi/aws'; | |
const defaultTags = { Creator: 'pulumi' }; | |
const awsAccountId = pulumi.output(aws.getCallerIdentity()).accountId; | |
const infrastructureRole = new aws.iam.Role('infrastructure', { | |
name: 'infrastructure', | |
assumeRolePolicy: { | |
Version: '2012-10-17', | |
Statement: [{ | |
Effect: 'Allow', | |
Action: 'sts:AssumeRole', | |
Principal: { | |
AWS: pulumi.interpolate`${awsAccountId}`, | |
} | |
}], | |
}, | |
tags: defaultTags, | |
}) | |
new aws.iam.RolePolicyAttachment('infrastructure-admin', { | |
role: infrastructureRole.name, | |
policyArn: aws.iam.getPolicyOutput({ name: 'SystemAdministrator'}).arn, | |
}); | |
new aws.iam.RolePolicyAttachment('infrastructure-iam', { | |
role: infrastructureRole.name, | |
policyArn: aws.iam.getPolicyOutput({ name: 'IAMFullAccess' }).arn, | |
}); |
The IAM role is currently configured so that any IAM entity from the same AWS account is allowed to assume it. We attach the built in SystemAdministrator policy, which is a reasonable starting point for a role that will manage AWS resources. We also attach the built in IAMFullAccess policy, since this is not included in the other policy, and we are managing our IAM resources here.
Using the IAM role
Now that we have a role for managing infrastructure, let’s modify our IAM user so that we can take advantage of it, and decrease the permissions attached directly to it:
import * as aws from '@pulumi/aws'; | |
const defaultTags = { Creator: 'pulumi' }; | |
const timUser = new aws.iam.User('tim.myers', { | |
name: 'tim.myers', | |
tags: defaultTags, | |
}); | |
new aws.iam.UserPolicyAttachment('tim.myers-readonly', { | |
user: timUser.name, | |
policyArn: aws.iam.getPolicyOutput({ name: 'ReadOnlyAccess'}).arn, | |
}); | |
new aws.iam.UserPolicyAttachment('tim.myers-password', { | |
user: timUser.name, | |
policyArn: aws.iam.getPolicyOutput({ name: 'IAMUserChangePassword'}).arn, | |
}); | |
ew aws.iam.UserPolicy('tim.myers-infrastructure-role', { | |
user: timUser.name, | |
policy: { | |
Version: '2012-10-17', | |
Statement: [{ | |
Effect: 'Allow', | |
Action: 'sts:AssumeRole', | |
Resource: infrastructureRole.arn, | |
}] | |
}, | |
}); |
We’ve replaced the AdministratorAccess policy that was previously attached with a much lesser ReadOnlyAccess policy that will still let us navigate and view data in the AWS web console, but won’t let us make any changes. We’ve also added a new policy that lets our user assume the infrastructure IAM role.
With this done, we should also change our IaC configuration to use our new infrastructure role. With pulumi, this is done by configuring the stack:
config: | |
aws:region: us-west-2 | |
aws:assumeRole: | |
roleArn: arn:aws:iam::<AWS Account ID>:role/infrastructure |
With this set, when pulumi is ran, it will automatically first assume our infrastructure role before taking any actions.
Setting up Github Actions
Authenticating Github to AWS
The next step to automating our infrastructure is to set up some resource that will allow GHA to authenticate to our AWS account. Github has documentation on this process, but we’ll go ahead and look at the resources necessary:
import * as aws from '@pulumi/aws'; | |
const defaultTags = { Creator: 'pulumi' }; | |
const githubOIDC = new aws.iam.OpenIdConnectProvider('github', { | |
url: 'https://token.actions.githubusercontent.com', | |
thumbprintLists: ['15e29108718111e59b3dad31954647e3c344a231'], | |
clientIdLists: ['sts.amazonaws.com'], | |
tags: defaultTags, | |
}); | |
const githubRole = new aws.iam.Role('github', { | |
name: 'github', | |
assumeRolePolicy: { | |
Version: '2012-10-17', | |
Statement: [{ | |
Effect: 'Allow', | |
Action: 'sts:AssumeRoleWithWebIdentity', | |
Principal: { | |
Federated: githubOIDC.arn, | |
}, | |
Condition: { | |
StringLike: { | |
'token.actions.githubusercontent.com:sub': 'repo:timmyers/fearless:*', | |
}, | |
'ForAllValues:StringEquals': { | |
'token.actions.githubusercontent.com:iss': 'https://token.actions.githubusercontent.com', | |
'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com', | |
}, | |
}, | |
}], | |
}, | |
tags: defaultTags, | |
}); | |
new aws.iam.RolePolicy('github', { | |
role: githubRole.name, | |
name: 'github', | |
policy: { | |
Version: '2012-10-17', | |
Statement: [{ | |
Effect: 'Allow', | |
Action: 'sts:AssumeRole', | |
Resource: infrastructureRole.arn, | |
}], | |
}, | |
}); |
First we create an OIDC provider. This lets AWS know how to check that authentication claims it receives from Github are valid. Next is a new IAM role that GitHub actions will assume, and its principal is set to the OIDC provider. In the condition, the iss
and aud
fields are common boilerplate, while the sub
field allows for filtering of things like which GitHub repos are allowed. For more detail, see this section of the docs.
Finally, we add a policy to the role that allows it to assume our infrastructure
role. When a GHA authenticates with our AWS account, it will first assume the github
role, and then may do anything that role is allowed to. In this case, it will be to apply our IaC, which we have now configured to require assuming the infrastructure
role first.
Applying IaC in a GHA workflow
With all the IAM changes and additions we created throughout this post, we’re now finally ready to actually create a GHA workflow that can apply our IaC:
name: pulumi | |
'on': | |
push: | |
branches: | |
- main | |
pull_request: | |
types: | |
- opened | |
- synchronize | |
- reopened | |
- labeled | |
permissions: | |
id-token: write | |
contents: read | |
pull-requests: write | |
jobs: | |
run-pulumi: | |
runs-on: ubuntu-latest | |
steps: | |
- uses: actions/checkout@v3 | |
- uses: haya14busa/action-cond@v1 | |
id: pulumi-op | |
with: | |
cond: ${{ github.event_name == 'pull_request' }} | |
if_true: preview | |
if_false: up | |
- uses: aws-actions/configure-aws-credentials@v1 | |
with: | |
role-to-assume: arn:aws:iam::<AWS Account ID>:role/github | |
aws-region: us-west-2 | |
- run: yarn install | |
- uses: pulumi/actions@v3 | |
with: | |
command: ${{ steps.pulumi-op.outputs.value }} | |
comment-on-pr: true | |
env: | |
PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }} |
The workflow runs on pushes to the main
branch (to apply changes) and on pull requests (to preview changes). It sets the permissions necessary to get a token for authenticating to AWS, as well as to edit pull requests with a comment of the change introduced by it. In the job itself, note that we assume our github
role, which will be successful thanks to all the configuration we set up earlier, and then run the appropriate pulumi commands.
Summary
It took a bit of work to get there, but we’ve managed to fully automate our infrastructure! We can now use a GitOps flow to open a pull request with changes to our IaC, where we will see the actions that would be taken as a comment on the PR. Once we are happy with the code diffs and infrastructure preview, we can merge and the changes will automatically be applied to our AWS account! In the process we’ve also made some incremental improvements to our access control, decreasing the permissions attached directly to our user. Hooray!