Cross-Account Deployment Pipelines

Built with AWS CDK using Typescript

Ali Haugh
Slalom Build
10 min readJun 5, 2023

--

Photo by EJ Strat on Unsplash

Are you a developer?
Are you trying to build a CI/CD pipeline in AWS? Using CDK?
Are you trying to deploy applications across accounts within your shared organization?
Are you frustrated with a lack of documentation on how to do this?

If you’re still here then it sounds like you have landed in the right place. In this article, we use AWS CDK to create a deployment pipeline to test and deploy an application into the same account. Then, we iterate on the deployment pipeline to deploy an application from a shared AWS account to a development, test, and production AWS accounts.

A couple of things to note before we jump in:
1. The steps you define in your deployment pipeline do not have to line up with the steps we create here. There are a lot of variations on the steps in a deployment pipeline, and I chose a simple example.
2. This article assumes you have at least some experience working with AWS and Cloud Development Kit (CDK). All of the examples use the AWS CDK in Typescript v2.63, but they can be translated to any of the supported languages (Java, Python, .NET, Go).

Define the steps our deployment pipeline should have for CI/CD

First, let’s define the steps in your deployment pipeline and the order they should be triggered in, according to best practices. When creating a CI/CD (Continuous Integration/Continuous Deployment) pipeline, the first step in your will be a trigger that activates whenever new code is committed to the main branch of you application’s repository. After the pipeline is triggered, it will retrieve a copy of the main branch code and an initial set of tests should be run against the code. Such tests might include code security checks and unit tests.

Next are the build and deploy steps. Depending on the type of cloud application (serverless or containers) and the language of the application being deployed, these can be a single step in the pipeline or two separate steps. For simplicity, I will refer to them as a single step in this article and will focus on applications deployed using CDK. After the application is deployed, the last step encompasses the post-deployment tasks. This step can include tests like integration tests and smoke tests depending on your testing strategy. If any of these steps fail, the pipeline should stop running and log the error to be investigated and fixed.

Visual representation of CI/CD pipeline steps

Now that we have the steps of our pipeline outlined, we can translate them into an AWS CodePipeline using CDK.

Pipeline steps translated into CDK code

First, let’s go over the general parts of the CodePipeline we are incorporating into our deployment pipeline. A CodePipeline is made up of a set of stages, and stages contain actions. If any of those actions fail, the pipeline with stop running at the point of failure by default. When building out the CodePipeline in CDK, we first define all of the actions, then the stages the actions will go in, and finally the ordering of the stages through the CodePipeline itself.

The first action we want to define is the retrieval of the source code. In an AWS CodePipeline, this can be accomplished with a CodeStar source action when the source control is BitBucket or GitHub. Below is CDK code that will provision a CodeStar source action and the CodePipeline Artifact stores the source code to be used in the rest of the pipeline actions:

import * as PipelineActions from 'aws-cdk-lib/aws-codepipeline-actions';
import * as CodePipeline from 'aws-cdk-lib/aws-codepipeline';

...

const codeArtifact = new CodePipeline.Artifact('PipelineArtifact');
const sourceAction = new PipelineActions.CodeStarConnectionsSourceAction({
actionName: 'SourceAction',
owner: '<repo owner>',
repo: '<repo name>',
output: codeArtifact,
connectionArn: '<codestar connection arn for account>',
branch: 'main',
triggerOnPush: true
});

The connectionArn is the CodeStar connection ARN (Amazon Resource Name) for the connection between the source control account where the application repo and the AWS account pipeline will live. This needs to be created manually if you haven’t connected the this pipeline to your source control yet. To create the connection, I recommend using this set of instructions from AWS. To get the ARN of the connection you want to use, go to Developer Tools console while signed into the AWS account where the connection was created.

The rest of the pipeline steps are a set of CodeBuild Actions. CodeBuild Actions allow flexibility in the tasks they can complete by sourcing the buildspec with the task definitions from the application’s codebase. This enables the application team to control the buildspecs used in these actions.

Pipeline action: test

The first CodeBuild action we define is a for the testing stage of the pipeline. To create a CodeBuild action, we first need to create a CodeBuild PipelineProject for the action to use. The PipelineProject defines the build environment and the tasks to complete in the action. In the example below, the build environment uses a standard Linux image and a small compute type. It can be tailored to whichever image and size fits your needs. When defining the PipelineProject, we specify the buildspec to use in this action as a source file and give it a path to the buildspec (this is a path to the buildspec that lives in the application code — not the code that defines the pipeline). Below is the full CDK code that provisions the testing action:

import * as CodeBuild from 'aws-cdk-lib/aws-codebuild';
import * as PipelineActions from 'aws-cdk-lib/aws-codepipeline-actions';

...

const buildEnv: CodeBuild.BuildEnvironment = {
buildImage: CodeBuild.LinuxBuildImage.STANDARD_5_0,
computeType: CodeBuild.ComputeType.SMALL,
privileged: true
};

const testPipelineProject new CodeBuild.PipelineProject(
this,
'TestPipelineProject',
{
description: 'PipelineProject for test action',
environment: buildEnv,
buildSpec: CodeBuild.BuildSpec.fromSourceFilename('./buildspecs/test-buildspec.yml'),
timeout: cdk.Duration.minutes(60)
}
);

const testAction = new PipelineActions.CodeBuildAction({
actionName: 'TestAction',
project: testPipelineProject,
runOrder: 1,
input: codeArtifact
});

As mentioned earlier, using a buildspec within the repo for the application being deployed allows application team ownership and they can have full control over the tasks completed within the action to maintain separation of responsibility between the application team and the DevOps team. This will prevent toil in the future when the application team wants to alter or add new tasks to an action.

Pipeline actions: deploy and post-deploy

Then, it’s rinse and repeat for the deploy and post-deploy actions. In the spirit of DRY (Don’t Repeat Yourself), the code snippet below creates the three (test, deploy, post-deploy) CodeBuild actions with a minimal amount of code repeated.

import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as CodeBuild from 'aws-cdk-lib/aws-codebuild';
import * as PipelineActions from 'aws-cdk-lib/aws-codepipeline-actions';
...

export class DeploymentPipelineStack extends Stack {
constructor (scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
...

const testPipelineProject = this.provisionPipelineProject('Test', 'test pipeline project', './buildspecs/test-buildspec.yml');
const testAction = new PipelineActions.CodeBuildAction({
actionName: 'TestAction',
project: testPipelineProject,
runOrder: 1,
input: codeArtifact
});

const deployPipelineProject = this.provisionPipelineProject('Deploy', 'deploy pipeline project', './buildspecs/deploy-buildspec.yml');
const deployAction = new PipelineActions.CodeBuildAction({
actionName: 'DeployAction',
project: deployPipelineProject,
runOrder: 1,
input: codeArtifact
});

const postDeployPipelineProject = this.provisionPipelineProject('PostDeploy', 'post deploy pipeline project', './buildspecs/post-deploy-buildspec.yml');
const postDeployAction = new PipelineActions.CodeBuildAction({
actionName: 'PostDeployAction',
project: postDeployPipelineProject,
runOrder: 1,
input: codeArtifact
});
}

provisionPipelineProject(
actionPrefix: string,
description: string,
buildspecFilePath: string
): CodeBuild.IProject {
const buildEnv: CodeBuild.BuildEnvironment = {
buildImage: CodeBuild.LinuxBuildImage.STANDARD_5_0,
computeType: CodeBuild.ComputeType.SMALL,
privileged: true
};

return new CodeBuild.PipelineProject(
this,
`${actionPrefix}PipelineProject`,
{
description,
environment: buildEnv,
buildSpec: CodeBuild.BuildSpec.fromSourceFilename(buildspecFilePath),
timeout: cdk.Duration.minutes(60)
}
);
}
}

Adding permissions

Next, we add the permissions for the deployment role to create AWS resources.

import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as PipelineActions from 'aws-cdk-lib/aws-codepipeline-actions';
import * as IAM from 'aws-cdk-lib/aws-iam';
...

export class DeploymentPipelineStack extends Stack {
constructor (scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
...

const deployPipelineProject = this.provisionPipelineProject('Deploy', 'deploy pipeline project', './buildspecs/deploy-buildspec.yml');
this.addCdkAssumePermissionsToRole(deployPipelineProject.role);
const deployAction = new PipelineActions.CodeBuildAction({
actionName: 'DeployAction',
project: deployPipelineProject,
runOrder: 1,
input: codeArtifact
});
}

...

addDeploymentPermissionsToRole(projectRole: IAM.IRole | undefined) {
const policy = new IAM.PolicyStatement({ effect: IAM.Effect.ALLOW });
policy.addResources(
`arn:aws:iam::${this.account}:role/cdk-readOnlyRole`,
`arn:aws:iam::${this.account}:role/cdk-<cdk qualifier (default is hnb659fds)>-deploy-role-*`,
`arn:aws:iam::${this.account}:role/cdk-<cdk qualifier (default is hnb659fds)>-file-publishing-*`
);
policy.addActions('sts:AssumeRole', 'iam:PassRole');
if (projectRole)
projectRole.addToPrincipalPolicy(policy);
}
}

Putting actions together in pipeline

Now that all the actions are defined, we can define the stages of the pipeline and create the CodePipeline:

import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as CodePipeline from 'aws-cdk-lib/aws-codepipeline';
...

export class DeploymentPipelineStack extends Stack {
constructor (scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
...

const sourceStageProps: CodePipeline.StageProps = {
stageName: 'SourceStage',
actions: [sourceAction]
};

const deployStageProps: CodePipeline.StageProps = {
stageName: 'DeployStage',
actions: [deployAction]
};

const postDeployStageProps: CodePipeline.StageProps = {
stageName: 'PostDeployStage',
actions: [postDeployAction]
};

const deploymentPipeline = new CodePipeline.Pipeline(
this,
'DeploymentPipeline',
{
stages: [
sourceStageProps,
deployStageProps,
postDeployStageProps
]
}
);
}

...

}

Once the deployment pipeline is complete, we need to ensure the application codebase has the appropriate buildspecs (named correctly and in the right location of the codebase). Finally, we can deploy the DeploymentPipelineStack and the application will be automatically deployed whenever new commits are added to the main branch.

Deploy to multiple accounts

Deploying an application to multiple AWS accounts is based on Amazon’s hub and spoke model. A shared account deploys applications into separate accounts for development, test, production, etc. We need to update the pipeline CDK code, update the application CDK code, and create some connections between the accounts to achieve this with the CodePipeline created above.

AWS hub and spoke account layout

First, let’s look at the updates that need to be made to the CDK deployment pipeline code. An additional parameter is added to the constructor to pass in the account number and region for the application deployment. These values are set as environment parameters in the deploy actions. We also combine the deploy and postDeploy actions into a single stage, then create three stages with the combined actions (one for each account where the application is deployed).

import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as CodePipeline from 'aws-cdk-lib/aws-codepipeline';
import * as PipelineActions from 'aws-cdk-lib/aws-codepipeline-actions';
...

export class DeploymentAccountProps {
devAccountNumber: string;
testAccountNumber: string;
prodAccountNumber: string;
deploymentRegion: string;
};

export class DeploymentPipelineStack extends Stack {
constructor (scope: Construct, id: string, deploymentAccountProps: DeploymentAccountProps, props?: StackProps) {
super(scope, id, props);
const {
devAccountNumber,
testAccountNumber,
prodAccountNumber,
deploymentRegion
} = deploymentAccountProps;
...

const deploymentPipeline = new CodePipeline.Pipeline(
this,
'DeploymentPipeline',
{
stages: [
sourceStageProps,
this.createDeploymentStageProps('Dev', devAccountNumber, deploymentRegion, codeArtifact),
this.createDeploymentStageProps('Test', testAccountNumber, deploymentRegion, codeArtifact),
this.createDeploymentStageProps('Prod', prodAccountNumber, deploymentRegion, codeArtifact)
]
}
);
}

...

createDeploymentStageProps(stageName: string, targetAccount: string, targetRegion: string, inputArtifact: CodePipeline.Artifact): CodePipeline.StageProps {
const deployPipelineProject = this.provisionPipelineProject(`${stageName}DevDeploy`, `${stageName} deploy pipeline project`, './buildspecs/deploy-buildspec.yml');
this.addDeploumentPermissionsToRole(deployPipelineProject.role);
const deployAction = new PipelineActions.CodeBuildAction({
actionName: `${deployPipelineProject}DeployAction`,
project: deployPipelineProject,
runOrder: 1,
input: codeArtifact,
environmentVariables: {
ENV: { value: stageName.toLowerCase() },
ACCOUNT: { value: targetAccount },
REGION: { value: targetRegion }
}
});

const postDeployPipelineProject = this.provisionPipelineProject(`${stageName}PostDevDeploy`, `${stageName} post deploy pipeline project`, './buildspecs/post-deploy-buildspec.yml');
const postDeployAction = new PipelineActions.CodeBuildAction({
actionName: `${postDeployPipelineProject}DeployAction`,
project: postDeployPipelineProject,
runOrder: 2,
input: codeArtifact,
environmentVariables: {
ENV: { value: stageName.toLowerCase() },
}
});

const stageProps: CodePipeline.StageProps = {
stageName: `${stageName}Stage`,
actions: [deployAction, postDeployAction]
};

return stageProps;
}
}

Additionally, we need to update the function that adds deployment permissions to allow the pipeline project to assume a role from the target account (dev/test/prod).

import { Stack } from 'aws-cdk-lib';
import * as CodePipeline from 'aws-cdk-lib/aws-codepipeline';
import * as IAM from 'aws-cdk-lib/aws-iam';
...

export class DeploymentPipelineStack extends Stack {
...

createDeploymentStageProps(stageName: string, accountToDeployTo: string, inputArtifact: CodePipeline.Artifact): CodePipeline.StageProps {
this.addDeploymentPermissionsToRole(deployPipelineProject.role, accountToDeployTo);
}

addDeploymentPermissionsToRole(projectRole: IAM.IRole | undefined, targetAccount: string) {
const policy = new IAM.PolicyStatement({ effect: IAM.Effect.ALLOW });
policy.addResources(
`arn:aws:iam::${targetAccount}:role/cdk-readOnlyRole`,
`arn:aws:iam::${targetAccount}:role/cdk-<cdk qualifier (default is hnb659fds)>-deploy-role-*`,
`arn:aws:iam::${targetAccount}:role/cdk-<cdk qualifier (default is hnb659fds)>-file-publishing-*`
);
policy.addActions('sts:AssumeRole', 'iam:PassRole');
if (projectRole)
projectRole.addToPrincipalPolicy(policy);
}
}

To create the connections between the shared AWS account where the CodePipeline lives and the target accounts that the application will be deployed to with CDK, we need to re-boostrap the target accounts. This is done with CDK CLI for each target account (using the target account AWS CLI credentials).

cdk bootstrap <taget account number>/<deployment region> --cloudformation-execution-policies '<arn for policy limit permissions>' --trust <shared account number

Note: If you are deploying to multiple regions, this command needs to be run for each region within the accounts that you plan to deploy to.

The cloudformation-execution-policies needs to be either one of the default policies in an account (e.g. arn:aws:iam::aws:policy/AdministratorAccess) or one that has been provisioned in the target account already with the required permissions for the deployment to be successful.

Finally, the applications being deployed by CodePipeline using CDK need to be updated to deploy to the target accounts. Since we have set the target account and region as environment variables in the deployment pipeline’s deploy actions, we use those and add them to the stack props that are used in the call to super:

import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';

export class DeploymentPipelineApplicationStack extends Stack {
constructor(scope: Construct, id: string, deploymentEnv: string, props?: StackProps) {
props.env = {
account: process.env.ACCOUNT,
region: process.env.REGION
};

super(scope, id, props);

...

}

...

}

Re-cap

We defined a CI/CD pipeline and the steps we wanted it to contain. Then, we took that CI/CD definition and translated it into an AWS CodePipeline that deploys an application. Finally, we iterated on the pipeline to deploy from a shared account into development, test, and production accounts and set up the permissions to be able to deploy across accounts in an AWS organization.

--

--