[Terraform]CodeBuildでTerraformリポジトリのCIを行う

# 概要
CodeBuildサービスを利用してterraformリポジトリの継続的インテグレーションが行えるようになるまでの設定手順をハンズオンライクに記載します。

![Untitled Diagram (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/552343/2f6c5d09-3937-6459-3cf9-98c9cf063cba.png)


# 前提
- Githubでtfファイルを管理している
- AWSアカウントを保持している
- ステートファイルをS3バケットで管理している

# 実施すること
AWS上の`CodeBuild`サービスを用いて、`Github`で管理している`terraform`コードに修正が行われた際、修正プルリクエストの更新等をトリガーとして、自動的に以下を行うようなプロジェクトを作成します。

- フォーマット確認（format）
- バリデーション（validate）
- 静的解析（lint）
- 実行計画（plan）
- 実行計画の通知（tfnotify）

## フォーマット確認
`terraform fmt`コマンドではファイルにフォーマットをかけたり、未フォーマットのファイルを検知を行ったりします。

```bash:Terminal
$ terraform fmt -recursive -check
```

- `-recursive`オプション：現在のディレクトリから再帰的にtfファイルを探して実行する
- `-check`オプション：formatの正しくないファイルがあった場合にexit codeが0以外になる

なお、`-check`オプションをつけない場合は、自動的に全ファイルのフォーマットが行われ、フォーマット対象となったファイル名一覧が返却されます。

## バリデーション
`terraform validate`コマンドで、変数が未定義かどうか、構文にエラーが存在するかといったことをチェックします。
ただし、`validate`を行う前に`init`を実行しておく必要があります。

```bash:Terminal
$ terraform init
$ terraform validate
```

`validate`をはじめ、これ以降のコマンドに関しては`fmt`のように再帰的に実行可能なオプションが用意されていないため、自前でtfファイルが存在するディレクトリ一覧を取得し、それぞれのディレクトリに対してコマンド実行するといった工夫が必要となります。
シェルコマンドでこれを解決する一例を以下に示しておきます。

```bash:Terminal
$ find ${base_dir} -type f -name "*.tf" -exec dirname {} \; | sort -u | xargs -I {} terraform validate {}
```

## 静的解析
[tflint](https://github.com/terraform-linters/tflint)を用いることでtfファイルの静的解析を行います。
手元で使う場合はbrewによるインストールが必要となります。

```bash:Terminal
$ brew install tflint
```

たとえば、次のように存在しないリソース種別を定義しているファイルを検知することが可能です。

```terraform:main.tf
resource "aws_instance" "tutorial" {
  ami           = data.aws_ami.latest.image_id
  instance_type = "t2.micro2"               # 存在しないEC2インスタンスタイプを指定
  iam_instance_profile = "unknown_profile"  # 存在しないプロファイルを指定
}
```

```bash:Terminal
$ tflint
1 issue(s) found:

Error: "t2.micro2" is an invalid value as instance_type (aws_instance_invalid_type)

  on main.tf line 33:
  33:   instance_type = "t2.micro2"
```

AWS APIを用いてより詳細な解析を行うために`--deep`オプションが用意されています。
上記の例では、このオプションを付与することで`iam_instance_profile`が不正であることも検知することが可能となります。
なお、`--deep`オプションを利用する際にはAWS APIを利用するため、実行時のregion指定およびAWSへのCredentials設定が必要となります。

```bash:Terminal
$ tflint --deep --region=ap-northeast-1
2 issue(s) found:

Error: "t2.micro2" is an invalid value as instance_type (aws_instance_invalid_type)

  on main.tf line 33:
  33:   instance_type = var.aws_instance_type

Error: "unknown_profile" is invalid IAM profile name. (aws_instance_invalid_iam_profile)

  on main.tf line 34:
  34:   iam_instance_profile = "unknown_profile"
```

`validate` `tflint` のそれぞれに得意・不得意があるようなので、組み合わせて使うのが良いのではないかと考えています。

## 実行計画
`terraform plan`を利用することで、実行計画を取得します。
通常実行するケースでは特にオプションをつける必要性はありませんが、今回のCodeBuildやcircleci上で実行する場合、実行結果ログを見やすくするために`-no-color`オプションを付与することが好ましいです。（実行結果が色つきではなく[ANSIエスケープコード](https://www.mm2d.net/main/prog/c/console-02.html)で表示されるため）
また、`plan`を行うためには`init`が前提となります。

```bash:Terminal
$ terraform init
$ terraform plan

Refreshing Terraform state in-memory prior to plan...
The refreshed state will be used to calculate this plan, but will not be
persisted to local or remote state storage.

data.aws_ami.latest: Refreshing state...

------------------------------------------------------------------------

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # aws_instance.tutorial will be created
  + resource "aws_instance" "tutorial" {
      + ami                          = "ami-0a1c2ec61571737db"
      + arn                          = (known after apply)
      + associate_public_ip_address  = (known after apply)
      + availability_zone            = (known after apply)
      + cpu_core_count               = (known after apply)
      + cpu_threads_per_core         = (known after apply)
      + get_password_data            = false
      + host_id                      = (known after apply)
      + id                           = (known after apply)
      + instance_state               = (known after apply)
      + instance_type                = "t2.micro"
・・・（中略）
    }

Plan: 1 to add, 0 to change, 0 to destroy.

------------------------------------------------------------------------
```

## 実行計画の通知
[tfnotify](https://github.com/mercari/tfnotify)を用いることで、GithubのPRやSlackへ`plan`の実行結果を通知することができます。あらかじめ実行計画を見ることができる点が非常に便利です。
対象が複数環境となる場合でGithub PRへの通知設定を行っている場合、ややPRのコメントが流れやすくなってしまうため多少運用面での工夫が必要となるかもしれません。
`terraform plan`をパイプラインで渡して実行します。

```bash:Terminal
$ terraform plan | tfnotify plan --config tfnotify.yml
```
<img width="909" alt="スクリーンショット 2020-06-27 15.37.51.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/552343/44ea1e2b-8bc5-bce9-4281-0fbf936aa972.png">



通知先や通知内容の設定は任意のymlファイルに記載することができます。（詳細は[こちら](https://github.com/mercari/tfnotify#configurations)を参照）
なお、Githubへ連携を行うためにはアクセス用トークンを発行する必要があります。（後述）
一例を以下に示します。

```yml:tfnotify.yml
ci: codebuild  # ciを行う場所　circleciならcircleci
notifier:
  github:                  # 今回はgithubへ通知
    token: $GITHUB_TOKEN   # github連携用トークン
    repository:
      owner: "mintak21"    # 通知先リポジトリのオーナーユーザー名称
      name: "terraform"    # 通知先リポジトリ名称
terraform:
  plan:
    # 通知結果表示テンプレートを定義(html利用可能)
    template: |
      {{ .Title }}
      {{ .Message }}
      {{if .Result}}<pre><code> {{ .Result }} </pre></code>{{end}}
      <details><summary>Details (Click me)</summary>
      <pre><code> {{ .Body }} </pre></code></details>
    # 実行結果ごとのアクション定義（Github限定）
    when_add_or_update_only:   # addかupdateだけの場合、add-or-updateラベルをつける
      label: "add-or-update"
    when_no_changes:
      label: "no-changes"
    when_destroy:              # destroyがある場合、destroyラベルをつけ、WARNINGを付記
      label: "destroy"
      template: |
        ## :warning: WARNING: Resource Deletion will happen :warning:

        This plan contains **resource deletion**. Please check the plan result very carefully!
    when_plan_error:
      label: "error"
```
使用できるテンプレート変数

|変数|概要|
|:--|:--|
| {{ .Title }} | ## Plan result |
| {{ .Message }} | tfnotify コマンド実行時の `--message`オプションで指定した内容 |
| {{ .Result }} | `plan`最後の1行 ex: `Plan: 1 to add, 0 to change, 0 to destroy.` |
| {{ .Body }} | `plan`の内容 |
| {{ .Link }} | `tfnotify`を実行したCIページへのリンク |


# CodeBuildプロジェクトの作成
CIにて確認したい内容、確認の方法を上記で見ることができたので、ここからそれを実行するためのCodeBuildプロジェクトを作成していきましょう。もちろん`terraform`で作成します。
次のようなディレクトリ構成とします。
コードの全容は[リポジトリ](https://github.com/mintak21/terraform-old)をご参照ください。

```text
├── cicd/             # CodeBuild設定管理ファイル
│  ├── backend.tf
│  ├── buildspec.yml  # ビルドフェーズ定義ファイル
│  ├── main.tf        # エントリポイント
│  ├── output.tf      # 出力定義
│  ├── providers.tf
│  ├── terraform.tfvars
│  ├── tfnotify.yml   # tfnotify設定
│  └── variables.tf
├── files/            # CIの対象とするファイル群（階層はイメージ）
│  ├── development/
│  │ └── main.tf
│  └── staging/
│    └── main.tf
├── module/        # モジュール
└── scripts/       # CodeBuildビルドフェーズで使用するスクリプト群
   └── ci/
      ├── format.sh
      ├── install_packages.sh
      ├── lint_aws.sh
      ├── plan.sh
      ├── settings.sh
      └── validate.sh
```

## 1. Github連携トークンの払い出し
githubと連携を行うため必要なトークンを作成します。
[こちら](https://help.github.com/ja/github/authenticating-to-github/creating-a-personal-access-token)を参照して発行を行ってください。

## 2. パラメータストアへのトークン登録
連携トークンは秘匿情報にあたります。`CodeBuild`の環境変数に設定してもよいのですが、よりセキュアに管理するためトークン情報は[AWS Systems Manager パラメータストア](https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/systems-manager-parameter-store.html)に保存し、`CodeBuild`からパラメータ参照することとします。ここでは`aws-cli`コマンドを使用して保存します。

```bash:Terminal
aws ssm put-parameter --type SecureString --name パラメータ名 --value Githubトークン
```

## 3. CodeBuild用IAMロール払い出し
CodeBuild用のIAMロール作成を行います。
モジュールの詳細を割愛しますが、指定したポリシードキュメントを持つIAMポリシーを作成、サービスロールとしてIAMロールを作成し、これらの関連付けを行っています。
また、少し楽をするため、アタッチするポリシーはCodeBuild全権限としています。通常は必要な権限を絞って適用すべきである点ご注意ください。

```terraform:main.tf
data "aws_iam_policy" "administrator" {
  arn = "arn:aws:iam::aws:policy/AWSCodeBuildAdminAccess" // !!!CodeBuildAdmin権限注意!!!
}

module "service_role_for_continuous_check" {
  source = "./../module/aws/iam"

  role_name                     = var.codebuild_role_name
  policy_name                   = var.codebuild_role_policy_name
  policy                        = data.aws_iam_policy.administrator.policy
  principal_service_identifiers = ["codebuild.amazonaws.com"]
}
```

## 4. CodeBuildプロジェクト定義
次にCodeBuildプロジェクト作成のためのコードを書いていきます。

### 4.1. CI実行用スクリプトの作成
ビルドフェーズで`lint`や`plan`を行うにあたり対象とするディレクトリに対して再帰的に実行したいため、ラッパーシェルを作成します。例えば`plan`を行うシェルは以下のように作成しています。
なおここで使用している`CODEBUILD_SRC_DIR`という環境変数を使用することで`CodeBuild`でビルドする際のルートディレクトリを取得することが可能です。

```bash:plan.sh
target_dirs=`find ../../${base_dir} -type f -name "*.tf" -exec dirname {} \; | sort -u`
for target in ${target_dirs}
do
  cd ${target}
  terraform init -input=false -no-color
  terraform plan -input=false -no-color | \
  tfnotify --config ${CODEBUILD_SRC_DIR}/cicd/tfnotify.yml plan --message "$(date)"
done
```

また、今回ベースイメージにterraform:lightを使用しますが、`tflint`や`tfnotify`は含まれていないため、installフェーズでこれらを取得するためのスクリプトも作成しておきます。（専用のカスタムイメージを作成し、これを使用するようにすればinstallフェーズ自体のスキップすることができるかもしれません）

```bash:install_packages.sh
TF_LINT_VER="v0.25.0"
TF_NOTIFY_VER="v0.7.0"

install_tflint() {
  REPO_URL="https://github.com/terraform-linters/tflint/releases/download"
  DL_LINK="${REPO_URL}/${TF_LINT_VER}/tflint_linux_amd64.zip"
  wget ${DL_LINK} -P /tmp
  unzip /tmp/tflint_linux_amd64.zip -d /tmp
  mv /tmp/tflint /bin/tflint
  echo "install tflint"
}

install_tfnotify() {
  REPO_URL="https://github.com/mercari/tfnotify/releases/download"
  DL_LINK="${REPO_URL}/${TF_NOTIFY_VER}/tfnotify_linux_amd64.tar.gz"
  wget ${DL_LINK} -P /tmp
  tar zxvf /tmp/tfnotify_linux_amd64.tar.gz -C /tmp
  mv /tmp/tfnotify /bin/tfnotify
  echo "install tfnotify"
}

install_tflint
install_tfnotify
```

### 4.2. ビルドフェーズ定義ファイルの作成
`CodeBuild`でのビルドは`SUBMITTED`フェーズ〜`FINALIZING`フェーズまで、一連のフェーズにて行われます。
これら各種フェーズで何を行うかをymlファイルで設定することが可能です。
今回は`INSTALL`フェーズで必要となるパッケージをインストールし、`BUILD`フェーズでformat/validate/lint/planを行う（シェルを呼び出す）ように設定します。

```yml:buildspec.yml
version: 0.2

env:
  parameter-store:
    GITHUB_TOKEN: "2で指定したパラメータ名"

phases:
  install:
    commands:
      - ${CODEBUILD_SRC_DIR}/scripts/ci/install_packages.sh
  build:
    commands:
      - ${CODEBUILD_SRC_DIR}/scripts/ci/format.sh
      - ${CODEBUILD_SRC_DIR}/scripts/ci/validate.sh
      - ${CODEBUILD_SRC_DIR}/scripts/ci/lint_aws.sh
      - ${CODEBUILD_SRC_DIR}/scripts/ci/plan.sh
```

### 4.3. プロジェクトの作成ファイル
一例を示しますが、重要な項目は次となります。

- `service_role`：紐付けるサービスロール。払い出したロールのarn値を指定する
- `source`ブロック：CIの実行対象とするリポジトリの定義
  - `report_build_status`：`true`: CI結果の通知を行う。デフォルト`false`なので有効化しない限りGithub側へ反映されない
- `environment`ブロック：CIを行う環境の準備
    - `image`：使用するDockerイメージ
    - `compute_type`：マシンスペック。特に意識しなければ最小構成の`BUILD_GENERAL1_SMALL`を選択すべし
- `logs_config`ブロック：ログ出力設定。`CloudWatch`はONにしておくとよいかと

```terraform:main.tf
resource "aws_codebuild_project" "continuous_check" {
  // プロジェクトの設定
  name          = var.codebuild_project_name
  description   = "continuous integration project for terraform repogistory"
  badge_enabled = false
  tags = {
    Author = "mintak21"
  }

  // ソース
  source {
    type                = "GITHUB"
    location            = var.github_repository_location
    git_clone_depth     = 1
    report_build_status = true // リポジトリ側へ結果通知
    buildspec           = var.buildspec_settings
  }

  // 環境
  environment {
    image           = "hashicorp/terraform:light" // カスタムイメージURL
    type            = "LINUX_CONTAINER"           // 環境タイプ
    compute_type    = "BUILD_GENERAL1_SMALL"      // コンピューティングタイプ
    privileged_mode = false
  }
  // サービスロール
  service_role = module.service_role_for_continuous_check.this_aws_iam_role_arn
  // タイムアウト
  build_timeout = "30"
  // キュータイムアウト
  queued_timeout = "60"

  // アーティファクト
  artifacts {
    type = "NO_ARTIFACTS"
  }

  // キャッシュ
  cache {
    type  = "LOCAL"
    modes = ["LOCAL_SOURCE_CACHE"]
  }

  // ログ
  logs_config {
    cloudwatch_logs {
      status      = "ENABLED"
      group_name  = "mintak"
      stream_name = "logs-for-${var.codebuild_project_name}"
    }

    s3_logs {
      status = "DISABLED"
    }
  }
}
```

## 5. Webhook作成ファイル
プロジェクトを作成しましたが、このままでは`Github`にpushやPRの作成等を行っても、`CodeBuild`へ連携されることがありません。そこでこれらのイベントをトリガーとしてプロジェクトのビルドが実行されるようにWebhookを作成します。
`filter`ブロックで検知するイベントの種別、パターンを記述します。ここでは以下をトリガーとしてビルド実行される設定を行います。

- PR作成・更新
- masterブランチへのpush

```terraform:main.tf
resource "aws_codebuild_webhook" "continuous_check" {
  project_name = aws_codebuild_project.continuous_check.name

  // PR作成・更新時
  filter_group {
    filter {
      type    = "EVENT"
      pattern = "PULL_REQUEST_CREATED"
    }
  }

  filter_group {
    filter {
      type    = "EVENT"
      pattern = "PULL_REQUEST_UPDATED"
    }
  }

  // masterブランチpush時
  filter_group {
    filter {
      type    = "EVENT"
      pattern = "PUSH"
    }

    filter {
      type    = "HEAD_REF"
      pattern = "master"
    }
  }
}
```

## 6. プロジェクト作成
ここまでで必要なコード類がそろったので、いよいよ`apply`でプロジェクトを作成します。

```bash:Terminal
$ cd cicd
$ terraform init
$ terraform apply
```

## 7. Githubブランチの設定
ここまででCIは実行されるようになりましたが、CIに失敗した場合にPRがマージが可能な状態は避けたいものです。
そこでGithubリポジトリのブランチ保護設定を行い、PRのマージ可能条件に「CIの成功」を設定します。`master`ブランチおよび`develop`ブランチに設定するのが良いでしょう。

<img width="1223" alt="スクリーンショット 2020-06-28 1.37.40.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/552343/8c664074-31c0-30d3-fb8c-4f90d3034426.png">



# 試してみる
では実際に`files`ディレクトリ以下のtfファイルに任意の変更を行い、プルリクエストを作成してみましょう。
`CodeBuild`が実行され、以下が通知されれば正しく設定が完了しています。

- `tfnotify`により`plan`結果（コメント）
- ビルドステータス
<img width="938" alt="スクリーンショット 2020-06-28 2.03.59.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/552343/f3818648-8299-a5ea-d002-7a94e676a151.png">



おつかれさまでした！


# References
- [AWS CodeBuild](https://aws.amazon.com/jp/codebuild/)
- [Terraform aws_codebuild_project](https://www.terraform.io/docs/providers/aws/r/codebuild_project.html)
- [tflint(Github)](https://github.com/terraform-linters/tflint)
- [tfnotify(Github)](https://github.com/mercari/tfnotify)
- [CodePipeline で簡単 Terraform CI/CD パイプラインの実装](https://dev.classmethod.jp/articles/simple-terraform-cicd-pipeline-with-aws-codepipeline/)