Angular×AWSのCI/CD術

# はじめに
以前、AngularとAWSでアプリをデプロイする方法についての[記事](https://qiita.com/so_oki/items/a05398331fd2f2e792d6)を書きましたが、最近の開発だとCI/CDはほぼ必須と言えるほど重要視されています。
ということで今回はCI/CDの実現方法の一つを書いていこうと思います。

# 目標とするアーキテクチャ
![codepipeline.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/937419/fb7cb753-a6c5-4006-7628-e5671573b794.png)
ユーザーがGitHubにソースをpushするとCodePipelineが実行されCodeBuildでビルドし、成果物をCloudFrontが見ているS3に配置するというアーキテクチャを目標とします。


# codestar-connection登録
今回、GitHubから取得するのにcodestar-connectionを使います。
![settings.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/937419/f2747de7-2f31-ad61-399e-d9062a70e1c7.png)
Codepipelineの設定から接続を選択し、接続名とプロバイダーを選択します。
ここでGitHubに接続するを押下するとGitHubのログイン画面が開くのでログインします。
![settings2.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/937419/fad97366-19bd-c90f-1c17-a8e9120de7fa.png)
上記画面が表示されるので新しいアプリをインストールするを押下します。
![add_app - コピー.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/937419/3df3e6c6-531b-e8c5-5454-1902a5a432e7.png)
GitHubの画面が開きアプリをGitHub側でAWSから接続するためのアプリをインストールします。
ここで、repositoryを制限したい場合にはOnly select repositoriesでrepositoryを制限します。

※GitHub側でAWSに接続するアプリは一つしか設定できないのでAWSで使用するrepositoryはすべて登録しておきます。
戻った画面で接続ボタンを押下するとcodestar-connectionが作られるのでそのARNをメモしておきます。

# CodeBuildのためのbuildspecを作成
AngularのビルドをCodeBuildで行うためのbuildspecを記載していきます。
全容は下記になります。

``` yml:buildspec.yml
version: 0.2

phases:
  install:
    runtime-versions:
      nodejs: 12
  pre_build:
    commands:
      - npm ci
      - npm run ng test
  build:
    commands:
      - npm run ng build -- --c ${environment}
artifacts:
  files:
    - 'dist/hello-world/*'
  name: app-$(date +%Y-%m-%d)
  discard-paths: yes
```
まずinstall部分でビルド環境を指定します。
今回はnodejs 12が入っている環境を指定します。

``` yml
  install:
    runtime-versions:
      nodejs: 12
```

ビルド前にパッケージをインストールする必要があるためpre_buildフェーズでnpm ciおよびテストもビルド前に実行します。

``` yml
  pre_build:
    commands:
      - npm ci
      - npm run ng test
```

ビルドの引数は環境によって異なることが多いので変数化しておきます。

``` yml
  build:
    commands:
      - npm run ng build -- --c ${environment}
```

成果物はdist/プロジェクト名/配下に入るのでパスをartifactで指定します。

``` yml
artifacts:
  files:
    - 'dist/hello-world/*'
  name: app-$(date +%Y-%m-%d)
  discard-paths: yes
```

このbuildspec.ymlはプロジェクトのルートディレクトリに配置しておきます。

# Template作成
今回もリソースの作成はCloudFormationのテンプレートで行っていきます。
## パラメータ設定

``` yaml:template.yaml
Parameters:
  CodeStarConnectionArn:
    Type: String
    Description: "repogitory connection"
  RepositoryName:
    Type: String
  BranchName:
    Type: String
  BuildEnv:
    Type: String
    Description: "build parameter"
```
パラメータには下記の4つを設定します。

|パラメータ名|説明|
|:-----------|:------------|
|CodeStarConnectionArn|codestar-connectionのARN|
|RepositoryName|ソースが入っているRepository名|
|BranchName|ソースが入っているブランチ名|
|BuildEnv|環境名（今回はAngularのビルド引数として利用）|
BuildEnvはこれまでの手順で作成したbuildspec.ymlの環境変数に使われます。
※ブランチ名などは環境名のパラメータから[Mappings](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/mappings-section-structure.html)で引いた方がいい場合もあります。。

## S3バケット

``` yaml:template.yaml
Resource:
  ArtifactStoreBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub ${AWS::StackName}-${AWS::Region}-cf-artirfactstore
```
codepipelineで成果物を受け渡すための入れ物としてS3バケットが必要となるのではじめに用意します。
## Codepipeline作成

``` yaml:template.yaml
  AppPipeline:
    Type: AWS::CodePipeline::Pipeline
    Properties:
      RoleArn: !GetAtt PipelineRole.Arn
      ArtifactStore:
        Location: !Ref ArtifactStoreBucket
        Type: S3
      Stages:
        - Name: Source
          Actions:
            - Name: download-source
              ActionTypeId:
                Category: Source
                Owner: AWS
                Version: 1
                Provider: CodeStarSourceConnection
              Configuration:
                ConnectionArn: !Ref CodeStarConnectionArn
                FullRepositoryId: !Ref RepositoryName
                BranchName: !Ref BranchName
              OutputArtifacts:
                - Name: SourceObject
        - Name: Build
          Actions:
            - Name: CodeBuild
              ActionTypeId:
                Category: Build
                Owner: AWS
                Version: 1
                Provider: CodeBuild
              Configuration:
                ProjectName: !Ref AppBuild
              InputArtifacts:
                - Name: SourceObject
              OutputArtifacts:
                - Name: BuildObject
        - Name: Deploy
          Actions:
            - Name: Deploy
              ActionTypeId: 
                Category: Deploy
                Owner: AWS
                Provider: S3
                Version: 1
              Configuration:
                BucketName: !Ref AngularBucket
                Extract: true
                ObjectKey: app
              InputArtifacts:
                - Name: BuildObject
```
ここではCodepipelineの一連の流れを記述しています。
Githubで取得したソースをSourceObjectとしてBuildActionに渡しAppBuild（後述）でアプリのビルドを行い成果物をDeployActionでS3に配置しています。
## CodeBuild

``` yaml:template.yaml
  AppBuild:
    Type: AWS::CodeBuild::Project
    Properties:
      Artifacts:
        Type: CODEPIPELINE
      Environment:
        ComputeType: BUILD_GENERAL1_SMALL
        Image: aws/codebuild/standard:5.0-21.04.23
        Type: LINUX_CONTAINER
        EnvironmentVariables:
          - Name: environment
            Value: !Ref BuildEnv 
      ServiceRole: !GetAtt BuildRole.Arn
      Name: Angular_Build
      Source:
        Type: CODEPIPELINE

```
ビルドの詳細はソース上のbuildspec.ymlに書いてあるのでここはシンプルな記載となります。
buildspec.ymlで指定している環境変数名をenvironmentとしていたのでenvironmentという変数名でBuildEnvの中身を設定しています。

## Role周り
Role周りは記事の主旨と異なるので割愛して今回設定したテンプレートのみ記載します。
<details><summary>Role周りの設定</summary><div>

``` yaml:template.yaml
  BuildRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
        - Action:
          - sts:AssumeRole
          Effect: Allow
          Principal:
            Service: 
              - codebuild.amazonaws.com
        Version: '2012-10-17'
      Path: "/"
      Policies:
      - PolicyDocument:
          Statement:
          - Action:
            - logs:CreateLogGroup
            - logs:CreateLogStream
            - logs:PutLogEvents
            Effect: Allow
            Resource: arn:aws:logs:*:*:*
          Version: '2012-10-17'
        PolicyName: !Sub ${AWS::StackName}-${AWS::Region}-AWSCodeBuildLogPolicy-CW
      - PolicyDocument:
          Statement:
          - Action:
            - s3:*
            Effect: Allow
            Resource:
            - !Sub arn:aws:s3:::${AngularBucket}/*
            - !Sub arn:aws:s3:::${AngularBucket}
            - !Sub arn:aws:s3:::${ArtifactStoreBucket}/*
            - !Sub arn:aws:s3:::${ArtifactStoreBucket}
          - Action:
            - codebuild:*
            - lambda:InvokeFunction
            Effect: Allow
            Resource: 
              - '*'
          Version: '2012-10-17'
        PolicyName: !Sub ${AWS::StackName}-${AWS::Region}-AWSLambda-S3
      RoleName: !Sub ${AWS::StackName}-${AWS::Region}-AWSCodeBuildPolicy-CW

  PipelineRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
        - Action:
          - sts:AssumeRole
          Effect: Allow
          Principal:
            Service: 
              - codepipeline.amazonaws.com
        Version: '2012-10-17'
      Path: "/"
      Policies:
      - PolicyDocument:
          Statement:
          - Action:
            - logs:CreateLogGroup
            - logs:CreateLogStream
            - logs:PutLogEvents
            Effect: Allow
            Resource: arn:aws:logs:*:*:*
          Version: '2012-10-17'
        PolicyName: !Sub ${AWS::StackName}-${AWS::Region}-AWSCodePipelineLogPolicy-CW
      - PolicyDocument:
          Statement:
          - Action:
            - s3:*
            Effect: Allow
            Resource:
            - !Sub arn:aws:s3:::${AngularBucket}/*
            - !Sub arn:aws:s3:::${AngularBucket}
            - !Sub arn:aws:s3:::${ArtifactStoreBucket}/*
            - !Sub arn:aws:s3:::${ArtifactStoreBucket}
          - Action:
            - codebuild:*
            Effect: Allow
            Resource:
            - '*'
          - Action:
            - codestar-connections:GetConnection
            Effect: Allow
            Resource:
            - '*'
          Version: '2012-10-17'
        PolicyName: !Sub ${AWS::StackName}-${AWS::Region}-AWSLambda-S3
      RoleName: !Sub ${AWS::StackName}-${AWS::Region}-AWSCodePipelinePolicy-CW
```
</div>
# 全体
前回の記事の内容を含めたテンプレートの全容です。
<details><summary>テンプレート全体</summary><div>

``` yml:template.yml
Parameters:
  AngularBucketName:
    Type: String
    Default: "angular-app-bucket"
    Description: "S3 bucket to Create"
    AllowedPattern: "[a-zA-Z][a-zA-Z0-9_-]*"
  DirName:
    Type: String
    Default: "app"
    Description: "S3 bucket to Create"
    AllowedPattern: "[a-zA-Z][a-zA-Z0-9_-]*"
  CodeStarConnectionArn:
    Type: String
    Description: "repogitory connection"
  RepositoryName:
    Type: String
  BranchName:
    Type: String
  BuildEnv:
    Type: String
    Description: "build parameter"
Resources:
  AngularBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Ref AngularBucketName
  AngularBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref AngularBucket
      PolicyDocument:
        Statement:
          - Action: s3:GetObject
            Effect: Allow
            Resource: !Sub arn:aws:s3:::${AngularBucket}/${DirName}/*
            Principal:
              AWS: !Sub arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${CloudFrontOriginAccessIdentity}
  AngularDir:
    Type: Custom::S3CustomResource
    Properties:
      ServiceToken: !GetAtt AWSLambdaFunction.Arn
      the_bucket: !Ref AngularBucket
      dir_to_create: !Ref DirName
  AWSLambdaFunction:
    Type: "AWS::Lambda::Function"
    Properties:
      Description: "Work with S3 bucket"
      FunctionName: !Sub '${AWS::StackName}-${AWS::Region}-lambda'
      Handler: index.handler
      Role: !GetAtt AWSLambdaExecutionRole.Arn
      Timeout: 360
      Runtime: python3.6
      Code:
        ZipFile: |
          import boto3
          import cfnresponse
          def handler(event, context):
            # Init ...
            the_event = event['RequestType']
            print("The event is: ", str(the_event))
            response_data = {}
            s_3 = boto3.client('s3')
            # Retrieve parameters
            the_bucket = event['ResourceProperties']['the_bucket']
            dir_to_create = event['ResourceProperties']['dir_to_create']
            try:
              if the_event in ('Create', 'Update'):
                print("Requested folders: ", str(dir_to_create))
                print("Creating: ", str(dir_to_create))
                s_3.put_object(Bucket=the_bucket, Key=(dir_to_create + '/'))
              elif the_event == 'Delete':
                print("Deleting S3 content")
                b_operator = boto3.resource('s3')
                b_operator.Bucket(str(the_bucket)).objects.all().delete()
              print("Operation successfull")
              cfnresponse.send(event,context,cfnresponse.SUCCESS, response_data)
            except Exception as e:
              print("Operation failed...")
              print(str(e))
              response_data['Data'] = str(e)
              cfnresponse.send(event, context, cfnresponse.FAILED, response_data)
  AWSLambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
        - Action:
          - sts:AssumeRole
          Effect: Allow
          Principal:
            Service:
            - lambda.amazonaws.com
        Version: '2012-10-17'
      Path: "/"
      Policies:
      - PolicyDocument:
          Statement:
          - Action:
            - logs:CreateLogGroup
            - logs:CreateLogStream
            - logs:PutLogEvents
            Effect: Allow
            Resource: arn:aws:logs:*:*:*
          Version: '2012-10-17'
        PolicyName: !Sub ${AWS::StackName}-${AWS::Region}-AWSLambda-CW
      - PolicyDocument:
          Statement:
          - Action:
            - s3:PutObject
            - s3:DeleteObject
            - s3:List*
            Effect: Allow
            Resource:
            - !Sub arn:aws:s3:::${AngularBucket}/*
            - !Sub arn:aws:s3:::${AngularBucket}
          Version: '2012-10-17'
        PolicyName: !Sub ${AWS::StackName}-${AWS::Region}-AWSLambda-S3
      RoleName: !Sub ${AWS::StackName}-${AWS::Region}-AWSLambdaExecutionRole
  CloudForntDistribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Origins:
        - DomainName: !GetAtt AngularBucket.DomainName
          Id: AngularOrigin
          OriginPath: !Sub /${DirName}
          S3OriginConfig:
            OriginAccessIdentity: !Sub origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentity}
        Enabled: 'true'
        DefaultRootObject: index.html
        DefaultCacheBehavior:
          AllowedMethods:
          - GET
          - HEAD
          TargetOriginId: AngularOrigin
          ForwardedValues:
            QueryString: 'false'
          ViewerProtocolPolicy: allow-all
        PriceClass: PriceClass_200
  CloudFrontOriginAccessIdentity:
    Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
    Properties:
      CloudFrontOriginAccessIdentityConfig:
        Comment: !Ref AWS::StackName
  AppBuild:
    Type: AWS::CodeBuild::Project
    Properties:
      Artifacts:
        Type: CODEPIPELINE
      Environment:
        ComputeType: BUILD_GENERAL1_SMALL
        Image: aws/codebuild/standard:5.0-21.04.23
        Type: LINUX_CONTAINER
        EnvironmentVariables:
          - Name: environment
            Value: !Ref BuildEnv 
      ServiceRole: !GetAtt BuildRole.Arn
      Name: Angular_Build
      Source:
        Type: CODEPIPELINE
  AppPipeline:
    Type: AWS::CodePipeline::Pipeline
    Properties:
      RoleArn: !GetAtt PipelineRole.Arn
      ArtifactStore:
        Location: !Ref ArtifactStoreBucket
        Type: S3
      Stages:
        - Name: Source
          Actions:
            - Name: download-source
              ActionTypeId:
                Category: Source
                Owner: AWS
                Version: 1
                Provider: CodeStarSourceConnection
              Configuration:
                ConnectionArn: !Ref CodeStarConnectionArn
                FullRepositoryId: !Ref RepositoryName
                BranchName: !Ref BranchName
              OutputArtifacts:
                - Name: SourceObject
        - Name: Build
          Actions:
            - Name: CodeBuild
              ActionTypeId:
                Category: Build
                Owner: AWS
                Version: 1
                Provider: CodeBuild
              Configuration:
                ProjectName: !Ref AppBuild
              InputArtifacts:
                - Name: SourceObject
              OutputArtifacts:
                - Name: BuildObject
        - Name: Deploy
          Actions:
            - Name: Deploy
              ActionTypeId: 
                Category: Deploy
                Owner: AWS
                Provider: S3
                Version: 1
              Configuration:
                BucketName: !Ref AngularBucket
                Extract: true
                ObjectKey: app
              InputArtifacts:
                - Name: BuildObject
  BuildRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
        - Action:
          - sts:AssumeRole
          Effect: Allow
          Principal:
            Service: 
              - codebuild.amazonaws.com
        Version: '2012-10-17'
      Path: "/"
      Policies:
      - PolicyDocument:
          Statement:
          - Action:
            - logs:CreateLogGroup
            - logs:CreateLogStream
            - logs:PutLogEvents
            Effect: Allow
            Resource: arn:aws:logs:*:*:*
          Version: '2012-10-17'
        PolicyName: !Sub ${AWS::StackName}-${AWS::Region}-AWSCodeBuildLogPolicy-CW
      - PolicyDocument:
          Statement:
          - Action:
            - s3:*
            Effect: Allow
            Resource:
            - !Sub arn:aws:s3:::${AngularBucket}/*
            - !Sub arn:aws:s3:::${AngularBucket}
            - !Sub arn:aws:s3:::${ArtifactStoreBucket}/*
            - !Sub arn:aws:s3:::${ArtifactStoreBucket}
          - Action:
            - codebuild:*
            - lambda:InvokeFunction
            Effect: Allow
            Resource: 
              - '*'
          Version: '2012-10-17'
        PolicyName: !Sub ${AWS::StackName}-${AWS::Region}-AWSLambda-S3
      RoleName: !Sub ${AWS::StackName}-${AWS::Region}-AWSCodeBuildPolicy-CW
  ArtifactStoreBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub ${AWS::StackName}-${AWS::Region}-cf-artirfactstore
  PipelineRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
        - Action:
          - sts:AssumeRole
          Effect: Allow
          Principal:
            Service: 
              - codepipeline.amazonaws.com
        Version: '2012-10-17'
      Path: "/"
      Policies:
      - PolicyDocument:
          Statement:
          - Action:
            - logs:CreateLogGroup
            - logs:CreateLogStream
            - logs:PutLogEvents
            Effect: Allow
            Resource: arn:aws:logs:*:*:*
          Version: '2012-10-17'
        PolicyName: !Sub ${AWS::StackName}-${AWS::Region}-AWSCodePipelineLogPolicy-CW
      - PolicyDocument:
          Statement:
          - Action:
            - s3:*
            Effect: Allow
            Resource:
            - !Sub arn:aws:s3:::${AngularBucket}/*
            - !Sub arn:aws:s3:::${AngularBucket}
            - !Sub arn:aws:s3:::${ArtifactStoreBucket}/*
            - !Sub arn:aws:s3:::${ArtifactStoreBucket}
          - Action:
            - codebuild:*
            Effect: Allow
            Resource:
            - '*'
          - Action:
            - codestar-connections:GetConnection
            Effect: Allow
            Resource:
            - '*'
          Version: '2012-10-17'
        PolicyName: !Sub ${AWS::StackName}-${AWS::Region}-AWSLambda-S3
      RoleName: !Sub ${AWS::StackName}-${AWS::Region}-AWSCodePipelinePolicy-CW
```
</div>

こちらをCloudFormationのテンプレートに登録するとCIを含めたAngularのデプロイが出来ると思いますので是非お試しいただければと思います。
※必要であればCloudFrontのInvalidationのLambdaを最後に呼ぶとよりよいと思います。

# おわりに
今回CIを含めたリソースの作成を全てCloudFormationのテンプレートで記載してみましたが、環境を複数作ったり同じテンプレートを使いまわすという要件がない限りここまでテンプレート化するのはコスパが悪い気がしました。
ただそれぞれで何をやっているかという勉強には丁度いいと思うので是非お時間があればお試しいただければと思います。

# 参考
- [AWS公式ドキュメント](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-resource-codepipeline-pipeline.html)
- [CodePipelineからAWS Lambdaを呼び出してCloudFrontのキャッシュを削除(Invalidation)してみた](https://dev.classmethod.jp/articles/cloudfront-invalidation-using-codepipeline-custom-action/)