⚒️

CodeCommit × CodeBuildでプルリクエスト発行時に自動でCIが走るようにする

2021/03/15に公開

はじめに

仕事でCode3兄弟(CodeCommit、CodeBuild、CodePipeline)を使用している方もいるかと思いますが、他のCIツールと比べてかゆいところに手が届かない感じがしませんか?
私の場合、プルリクエストを作成したブランチに対して自動でユニットテストやLinterが走るようになればレビューアが確認する手間が省けていいなーと思ってやり方を考えていたのですが、CodeBuildやCodePipelineでCIを作ろうとするとあらかじめブランチ名を指定して作らなければならず、作業ブランチを作るたびにいちいちCodePipelineを作る羽目になりなかなかつらいです。
今回はAWS CodeCommitとCodeBuildでプルリクエスト発行時に自動でCIが走る仕組みを作りましたので、簡単にまとめてみました。

アーキテクチャ

大きく3つのパートに分かれています。

1つ目はCodeCommitリポジトリの作業ブランチでプルリクエストが発行されたイベントをCloudWatch Eventsで検知し、CodeBuildにビルドプロジェクトを作るパートです。

2つ目は作業ブランチがアップデートされたことをCloudWatch Eventsで検知し、作成したビルドプロジェクトを実行するパートです。

最後の3つ目は、プルリクエストがクローズされたことをCloudWatch Eventsで検知し、作成したビルドプロジェクトを削除するパートです。

使用する言語・デプロイツール

  • 言語
    • TypeScript
  • デプロイツール
    • Serverless Framework

IAMロール

CloudWatchから起動するLambdaに付与するIAMロールと、LambdaからCodeBuildへ引き渡すロールの2種類を定義していきます。

Lambdaに付与するIAMロールは以下の通りです。CodeBuildへロールを引き渡すためには"iam:GetRole""iam:PassRole"が必要なことに注意です。

Resources:
  AutoCreateandDeleteCodeBuildLambdaRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: "AutoCreateandDeleteCodeBuildLambdaRole-${self:provider.stage}"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Principal:
              Service: "lambda.amazonaws.com"
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Path: "/"
      Policies:
        - PolicyName: "AutoCreateandDeleteCodeBuildLambdaPolicy-${self:provider.stage}"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Resource: "*"
                Effect: Allow
                Action:
                  - "iam:GetRole"
                  - "iam:PassRole"
              - Resource: "*"
                Effect: Allow
                Action:
                  - "codebuild:*"
                  - "codecommit:*"
                  - "logs:CreateLogGroup"
                  - "logs:CreateLogStream"
                  - "logs:PutLogEvents"
                  - "logs:GetLogEvents"

LambdaからCodeBuildへ引き渡すロールは以下の通りです。

Resources:
  CodeBuildServiceRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: "CodeBuildServiceRole-${self:provider.stage}"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: codebuild.amazonaws.com
            Action: sts:AssumeRole
      Path: "/"
      Policies:
        - PolicyName: codecommit-pullrequest-codebuild-execute-role
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Resource: "*"
                Effect: Allow
                Action:
                  - codecommit:*
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
              - Resource: "*"
                Effect: Allow
                Action:
                  - s3:GetObject
                  - s3:PutObject
                  - s3:GetObjectVersion

Lambda関数

Lambda関数の実装方法は人それぞれだと思いますので、ここでは私の実装を簡単に解説します。
まずはCodeBuildの操作を行う関数をまとめてクラス化します。

import { CodeBuild } from 'aws-sdk';

class Codebuild {
    client: CodeBuild;

    constructor(serviceClient: CodeBuild) {
        this.client = serviceClient;
    }
    // ビルドプロジェクトを作成する
    createBuildProject(cloneUrlHttps: string, sourceVersion: string, buildProjectName: string) {
        const params = {
            artifacts: {
                type: 'NO_ARTIFACTS',
            },
            badgeEnabled: true,
            description:
                'This build project is triggered on create or update pull request in AWS CodeCommit.',
            environment: {
                computeType: 'BUILD_GENERAL1_SMALL',
                image: 'aws/codebuild/amazonlinux2-x86_64-standard:3.0-20.09.14',
                type: 'LINUX_CONTAINER',
            },
            name: buildProjectName,
            serviceRole: process.env.CODE_BUILD_ROLE_ARN,
            source: {
                type: 'CODECOMMIT',
                location: cloneUrlHttps,
            },
            sourceVersion,
        };

        return new Promise((resolve, reject) => {
            this.client.createProject(params, (err, data) => {
                if (err) {
                    console.log('Create Build Project Failure!!');
                    reject(err);
                } else {
                    console.log('Create Build Project Success!!');
                    resolve(data);
                }
            });
        });
    }
   // 作成したビルドプロジェクトを使ってビルドする
    startBuild(buildProjectName: string) {
        const params = {
            projectName: buildProjectName,
        };
        return new Promise((resolve, reject) => {
            this.client.startBuild(params, (err, data) => {
                if (err) {
                    console.log('Start Build Failure!!');
                    reject(err);
                } else {
                    console.log('Start Build Success!!');
                    resolve(data);
                }
            });
        });
    }
  // 作成したビルドプロジェクトを削除する
    deleteBuildProject(buildProjectName: string) {
        const params = {
            name: buildProjectName,
        };
        return new Promise((resolve, reject) => {
            this.client.deleteProject(params, (err, data) => {
                if (err) {
                    console.log('[Failure autoDeleteCodeBuild!!]');
                    reject(err);
                } else {
                    console.log('[Success autoDeleteCodeBuild!!]');
                    resolve(data);
                }
            });
        });
    }
}

export default Codebuild;

上記クラスを使って、CloudWatch Eventsから呼び出されるLambda関数を実装していきます。
まずはビルドプロジェクトを作成し、ビルドを実施するLambda関数です。

import * as AWS from 'aws-sdk';
import { Handler } from 'aws-lambda';
import Codebuild from '../../aws/codeBuild';

AWS.config.apiVersions = {
    codebuild: '2016-10-06',
};
const codebuild = new AWS.CodeBuild();

export const handler: Handler = async (event, context, callback) => {
    console.log('[Start autoCreateCodeBuild]');
    console.log(event);

    const repositoryName = event.detail.repositoryNames[0];
    const region = process.env.REGION;
    const cloneUrlHttps = `https://git-codecommit.${region}.amazonaws.com/v1/repos/${repositoryName}`;
    const sourceVersion = event.detail.sourceReference;
    const branchName = sourceVersion.split('/');
    const buildProjectName = `${repositoryName}-${branchName.slice(-1)[0]}`;
    const codeBuild = new Codebuild(codebuild);
    try {
        await codeBuild.createBuildProject(cloneUrlHttps, sourceVersion, buildProjectName);
	// 作成完了後ビルドを実行する
        await codeBuild.startBuild(buildProjectName);
        return callback(null, {
            statusCode: 200,
        });
    } catch (err) {
        return callback(err);
    }
};

次にビルドプロジェクトを実行するLambda関数です。

import * as AWS from 'aws-sdk';
import { Handler } from 'aws-lambda';
import Codebuild from '../../aws/codeBuild';

AWS.config.apiVersions = {
    codebuild: '2016-10-06',
};
const codebuild = new AWS.CodeBuild();

export const handler: Handler = async (event, context, callback) => {
    console.log('[Start autoStartCodeBuild]');
    console.log(event);

    const repositoryName = event.detail.repositoryNames[0];
    const sourceVersion = event.detail.sourceReference;
    const branchName = sourceVersion.split('/');
    const buildProjectName = `${repositoryName}-${branchName.slice(-1)[0]}`;
    const codeBuild = new Codebuild(codebuild);
    try {
        await codeBuild.startBuild(buildProjectName);
        return callback(null, {
            statusCode: 200,
        });
    } catch (err) {
        return callback(err);
    }
};

最後に、ビルドプロジェクトを削除するLambda関数です。

import * as AWS from 'aws-sdk';
import { Handler } from 'aws-lambda';
import Codebuild from '../../aws/codeBuild';

AWS.config.apiVersions = {
    codebuild: '2016-10-06',
};
const codebuild = new AWS.CodeBuild();

export const handler: Handler = async (event, context, callback) => {
    console.log('[Start autoDeleteCodeBuild]');
    console.log(event);

    const repositoryName = event.detail.repositoryNames[0];
    const sourceVersion = event.detail.sourceReference;
    const branchName = sourceVersion.split('/');
    const buildProjectName = `${repositoryName}-${branchName.slice(-1)[0]}`;
    const codeBuild = new Codebuild(codebuild);
    try {
        await codeBuild.deleteBuildProject(buildProjectName);
        return callback(null, {
            statusCode: 200,
        });
    } catch (err) {
        return callback(err);
    }
};

CloudWatch Events

CloudWatch Eventsの定義ファイルは以下の通りです。アーキテクチャの箇所でも説明した内容に沿って3つに分かれています。
まずはプルリクエスト発行を検知するための定義(AutoCreateCodeBuild)です。

Resources:
  AutoCreateCodeBuild:
    Type: AWS::Events::Rule
    Properties:
      Description: Auto create codeBuild
      EventPattern:
        source:
          - "aws.codecommit"
        detail-type:
          - "CodeCommit Pull Request State Change"
        detail:
          event:
            - "pullRequestCreated"
          pullRequestStatus:
            - "Open"
      Name: AutoCreateCodeBuild
      State: "ENABLED"
      Targets:#先ほど作成したLambda関数を指定する
        - Arn: arn:aws:lambda:ap-northeast-1:${self:provider.environment.ACCOUNT}:function:autoCreateCodeBuild-dev
          Id: autoCreateCodeBuild

次にブランチの変更を検知するための定義(AutoStartCodeBuild)です。

AutoStartCodeBuild:
    Type: AWS::Events::Rule
    Properties:
      Description: Auto start codeBuild
      EventPattern:
        source:
          - "aws.codecommit"
        detail-type:
          - "CodeCommit Pull Request State Change"
        detail:
          event:
            - "pullRequestSourceBranchUpdated"
          pullRequestStatus:
            - "Open"
      Name: AutoStartCodeBuild
      State: "ENABLED"
      Targets:#先ほど作成したLambda関数を指定する
        - Arn: arn:aws:lambda:ap-northeast-1:${self:provider.environment.ACCOUNT}:function:autoStartCodeBuild-dev
          Id: autoStartCodeBuild

最後にプルリクエストのクローズを検知する定義(AutoDeleteCodeBuild)です。

AutoDeleteCodeBuild:
    Type: AWS::Events::Rule
    Properties:
      Description: Auto delete codeBuild
      EventPattern:
        source:
          - "aws.codecommit"
        detail-type:
          - "CodeCommit Pull Request State Change"
        detail:
          event:
            - "pullRequestStatusChanged"
            - "pullRequestMergeStatusUpdated"
          pullRequestStatus:
            - "Closed"
      Name: AutoDeleteCodeBuild
      State: "ENABLED"
      Targets:#先ほど作成したLambda関数を指定する
        - Arn: arn:aws:lambda:ap-northeast-1:${self:provider.environment.ACCOUNT}:function:autoDeleteCodeBuild-dev
          Id: autoDeleteCodeBuild

これら定義に加えて、Lambdaを起動するPermissionを忘れないように注意してください。
上記定義の後ろに付け加えればOKです。

AutoCreateCodeBuildLambdaInvokePermission:
    Type: "AWS::Lambda::Permission"
    Properties:
      FunctionName: "autoCreateCodeBuild-${self:provider.stage}"# serverless.yml(後述)で定義する関数名を記載する
      Action: "lambda:InvokeFunction"
      Principal: "events.amazonaws.com"
      SourceArn:
        "Fn::GetAtt": [AutoCreateCodeBuild, Arn]# CloudWatch Events定義のリソース名を指定
    DependsOn: AutoCreateCodeBuild
  AutoStartCodeBuildLambdaInvokePermission:
    Type: "AWS::Lambda::Permission"
    Properties:
      FunctionName: "autoStartCodeBuild-${self:provider.stage}"# serverless.yml(後述)で定義する関数名を記載する
      Action: "lambda:InvokeFunction"
      Principal: "events.amazonaws.com"
      SourceArn:
        "Fn::GetAtt": [AutoStartCodeBuild, Arn]# CloudWatch Events定義のリソース名を指定
    DependsOn: AutoStartCodeBuild
  AutoDeleteCodeBuildLambdaInvokePermission:
    Type: "AWS::Lambda::Permission"
    Properties:
      FunctionName: "autoDeleteCodeBuild-${self:provider.stage}"# serverless.yml(後述)で定義する関数名を記載する
      Action: "lambda:InvokeFunction"
      Principal: "events.amazonaws.com"
      SourceArn:
        "Fn::GetAtt": [AutoDeleteCodeBuild, Arn]# CloudWatch Events定義のリソース名を指定
    DependsOn: AutoDeleteCodeBuild

serverless.yml

デプロイスクリプトであるserverless.ymlは以下の通りです。

service: automationPipeline

provider:
  name: aws
  runtime: nodejs12.x

  # you can overwrite defaults here
  stage: dev
  region: ap-northeast-1
  
  environment:
    ACCOUNT: "{Please replace with your IAM Account ID here.}"

# you can add packaging information here
package:
  exclude:
    - templates/**
  individually: true

custom:
  webpack:
    webpackConfig: ./webpack.config.js
    includeModules:
      packagePath: ./../package.json
      forceExclude:
        - aws-sdk

plugins:
  - serverless-webpack
  - serverless-pseudo-parameters

functions:
  autoCreateCodeBuild:
    handler: src/lambda/autoCreateCodeBuild/index.handler
    name: "autoCreateCodeBuild-${self:provider.stage}"
    role: "arn:aws:iam::#{AWS::AccountId}:role/AutoCreateandDeleteCodeBuildLambdaRole-${self:provider.stage}"
    description: "This function is test app."
    environment:
      CODE_BUILD_ROLE_ARN: "arn:aws:iam::#{AWS::AccountId}:role/CodeBuildServiceRole-${self:provider.stage}"
      REGION: "${self:provider.region}"
  autoStartCodeBuild:
    handler: src/lambda/autoStartCodeBuild/index.handler
    name: "autoStartCodeBuild-${self:provider.stage}"
    role: "arn:aws:iam::#{AWS::AccountId}:role/AutoCreateandDeleteCodeBuildLambdaRole-${self:provider.stage}"
    description: "This function is test app."
  autoDeleteCodeBuild:
    handler: src/lambda/autoDeleteCodeBuild/index.handler
    name: "autoDeleteCodeBuild-${self:provider.stage}"
    role: "arn:aws:iam::#{AWS::AccountId}:role/AutoCreateandDeleteCodeBuildLambdaRole-${self:provider.stage}"
    description: "This function is test app."

# you can add CloudFormation resource templates here
resources:
  - ${file(./templates/iam.yml)}
  - ${file(./templates/cloudwatchevent.yml)}

buildspec.yml

今回の趣旨とは少し離れますが、参考にbuildspec.ymlも載せておきます。

version: 0.2

env:
  variables:
    NODE_ENV: "development"

phases:
  install:
    runtime-versions:
      nodejs: 12

  pre_build:
    commands:
      - npm install

  build:
    commands:
      - npm run lint
      - npm test

注意点

今回作成したコードではリポジトリの区別まではしていないので、同じAWSアカウントを複数のチームで共有している場合は他のリポジトリに対しても実行されてしまいます。必要であれば、CloudWatch Eventsから渡される情報からリポジトリ名はわかりますので、それでうまくハンドリングしてください。

おわりに

今回ご紹介したシステムにより、以下が可能となります。

  • プルリクエストが発行されたときにビルドプロジェクトが自動で作成・実行される
  • プルリクエストが発行されたブランチに変更が入ると、自動でビルドされる
  • プルリクエストがマージなどによりクローズされると、ビルドプロジェクトが自動削除される

皆様の参考になれば幸いです。

Discussion