terraformとgithub actionsで一つのリポジトリで完結するCDを実装

お急ぎの方

https://github.com/sasakitimaru/terraform-learn

## 概要

terraform planとapplyをgithub actionsで実行しようとするとiamロールの管理がめんどくさいと思います
今回は

- プロジェクトと同一リポジトリでiamロールも管理して
- ワークフローを勝手に改竄されて実行されないような仕組みで
- OIDCでsecret keyをリポジトリにおかない

ような構成を実現しようと思います

なので今回は一つのプロジェクトで

1. terraformを実行するために必要なロールを作成するためのterraformプロジェクト:**terraform-iam**
2. 実際にアプリに必要なインフラを適用するためのterraformプロジェクト:**terraform-exec**

の二つを作ります（**名前つけたので以降は↑の名前で表します**

## 準備

基本的にはIaCでほとんどを管理しますが最初に手作業で準備するものが少しあります。

1. terraformを実行するiamロールとポリシーの作成
[OIDC](https://zenn.dev/kou_pg_0131/articles/gh-actions-oidc-aws)を使ってgithubの特定のリポジトリからのみassume roleできるものを作ります
2. terraformのstateを管理するS3リソース
リソースの状態を保存するため
3. terraform applyを実行するときにstateをロックするためのdynamodb

### terraformを実行するiamロールとポリシーの作成

terraform-iamのためのロールを作ります

まずはAWSコンソールからIAM > ロール へと移動しロールを作成します
カスタム信頼ポリシーを選択して下記の構成のJSONに差し替えて次へ行きます

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "",
            "Effect": "Allow",
            "Principal": {
                "Federated": "arn:aws:iam::<account_id>:oidc-provider/token.actions.githubusercontent.com"
            },
            "Action": "sts:AssumeRoleWithWebIdentity",
            "Condition": {
                "StringEquals": {
                    "token.actions.githubusercontent.com:sub": "repo:<your_account_name>/<your_repository>:event_name:push:base_ref::ref:refs/heads/main",
                    "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
                }
            }
        }
    ]
}
```

subの構成の説明ですがここでは下記を指定しています

- `repo:your_account_name/your_repository_name`
このロールに切り替えられるリポジトリを指定しています
私の場合を例にすると`sasakitimaru/terraform-learn`になります
- `event_name:push`
pushイベントだけを指定します
- `base_ref::ref:refs/heads/main`
宛先がmainブランチの場合だけを指定します

:::note warn
デフォルトではinclude_claimsにevent_name, base_refなどが含まれていません
なので対象のgithub repositoryにGitHub API を使って設定しておきます
:::

```json
gh api \
  --method PUT \
  -H "Accept: application/vnd.github+json" \
  -H "X-GitHub-Api-Version: 2022-11-28" \
  "/repos/<your-account-name>/<your-repository>/actions/oidc/customization/sub" \
  -F use_default=false \
  -f "include_claim_keys[]=repo" \
  -f "include_claim_keys[]=event_name" \
  -f "include_claim_keys[]=base_ref" \
  -f "include_claim_keys[]=ref"
```

参考

https://docs.github.com/en/rest/actions/oidc?apiVersion=2022-11-28#set-the-customization-template-for-an-oidc-subject-claim-for-a-repository

できたら次に、ポリシーを追加します

IAMFullAccessポリシーとカスタムでTerraformStateManageというロールを作成して付与します

```json
// TerraformStateManage
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "",
            "Effect": "Allow",
            "Action": [
                "s3:DeleteObject",
                "s3:GetObject",
                "s3:ListBucket",
                "s3:PutObject"
            ],
            "Resource": "*"
        }
    ]
}
```

本当ならIAMのポリシーも必要な権限だけ与えるのが良いです

### terraformのstateを管理するS3リソースの作成

パケットのバージョニングを有効にして全世界で一意な名前をつけて作成してください
terraform-iam, terraform-exec二つのterraformプロジェクトがあるのでこちらも2つ作成します

### terraform applyを実行するときにstateをロックするためのdynamodbリソースの作成

tfstateをロックするために必要です
terraform planをPRで実行することを想定しterraform-execにだけdynamodbリソースは当てようと思います
なのでこっちは一つだけ作ります
特に設定は変更せずに名前だけ決めて作成してください

## ワークフローの定義

準備が終わったのでmainブランチ宛のPR, pushをトリガーにするワークフローを作成していきます
今回のリポジトリの構成は以下です

```yaml
├──.github
│   └── workflows
│       ├── apply.yml
│       └── plan.yml
│
└── deployment
    ├── envs
    │   └── prod
    │       └── main.tf
    ├── init
    │   └── main.tf
    │   
    └── modules
        │
        └── network
            └── main.tf
```

initにはterraform-execのためのiam権限を定義するためのterraformプロジェクトを置きます（もうちょいマシな名前にすればよかった

envsに各環境のterraformプロジェクトを定義します
今回は簡単のためprod環境だけにしています

### PRでterraform planを実行する

<details><summary>plan.yml</summary>

```yaml
name: terraform plan

on:
  pull_request_target:
    branches:
      - main
      - develop

jobs:
  plan:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
      pull-requests: write

    env:
      ENVIRONMENT: null
      AWS_ACCOUNT_ID: null

    steps:
      - uses: actions/github-script@v6
        id: pr
        with:
          script: |
            const { data: pullRequest } = await github.rest.pulls.get({
              ...context.repo,
              pull_number: context.payload.pull_request.number,
            });
            return pullRequest

      - uses: actions/checkout@v4
        with:
          ref: ${{fromJSON(steps.pr.outputs.result).merge_commit_sha}}

      - name: Set environment based on branch
        run: |
          if [[ "${GITHUB_REF##*/}" == "main" ]]; then
            echo "ENVIRONMENT=prod" >> $GITHUB_ENV
            echo "AWS_ACCOUNT_ID=${{ secrets.AWS_PROD_ACCOUNT_ID }}" >> $GITHUB_ENV
          elif [[ "${GITHUB_REF##*/}" == "develop" ]]; then
            echo "ENVIRONMENT=dev" >> $GITHUB_ENV
            echo "AWS_ACCOUNT_ID=${{ secrets.AWS_DEV_ACCOUNT_ID }}" >> $GITHUB_ENV
          fi

      - name: Configure AWS credentials for IAM manager
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/terraform-exec
          aws-region: ${{ vars.REGION }}

      - uses: hashicorp/setup-terraform@v3

      - name: Terraform Format
        id: fmt
        run: |
          cd ${{ github.workspace }}/deployment/envs/${{ env.ENVIRONMENT }}
          terraform fmt -check

      - name: Terraform Init
        id: init
        run: |
          cd ${{ github.workspace }}/deployment/envs/${{ env.ENVIRONMENT }}
          terraform init

      - name: Terraform Plan
        id: plan
        env:
          TF_VAR_account_id: ${{ env.AWS_ACCOUNT_ID }}
          TF_VAR_region: ${{ vars.REGION }}
          TF_VAR_name_prefix: ${{ vars.NAME_PREFIX }}
        run: |
          cd ${{ github.workspace }}/deployment/envs/${{ env.ENVIRONMENT }}
          terraform plan -no-color

      - name: Comment PR
        uses: actions/github-script@v3
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const output = `### Terraform Plan 📖\n${{ steps.plan.outputs.stdout }}`;
            github.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `## Terraform checks for '${{ env.ENVIRONMENT }}'\n` + output
            })
```

</details>

要所だけ説明します

```yaml
on:
  pull_request_target:
    branches:
      - main
      - develop
```

ここではmain, developブランチへのPRをトリガーに指定しています
また、pull_requestではなくpull_request_targetを指定することでPRのターゲットのブランチのワークフローが使用されます
これにより誰かがワークフローを改竄してAWSリソースを操作しようとしても、改竄前のmain, developブランチのワークフローが使用されます
怪しいPRが来たらレビューでブロックできます

またpull_request_targetではbase_refがmainブランチとなるのでPRを出しているブランチにチェックアウトするには↓のようなひと工夫が入ります

```yaml
      # pullRequestを取得	
      - uses: actions/github-script@v6
        id: pr
        with:
          script: |
            const { data: pullRequest } = await github.rest.pulls.get({
              ...context.repo,
              pull_number: context.payload.pull_request.number,
            });
            return pullRequest

      # PRを出しているブランチのコミットハッシュを使用してcheckout
      - uses: actions/checkout@v4
        with:
          ref: ${{fromJSON(steps.pr.outputs.result).merge_commit_sha}}
```

次にterraform-execを使用するためにロールスイッチします

```yaml
      - name: Configure AWS credentials for IAM manager
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/terraform-exec
          aws-region: ${{ vars.REGION }}
```

このterraform-execのロールはこのリポジトリ内で定義して作成、更新していきます（後述
あとは普通にterraform initしてplanしてその結果をコメントに出力しているだけです

### mainブランチへのPushでterraform applyする

<details><summary>apply.yml</summary>

```yaml
name: Deploy Infrastructure

on:
  push:
    branches:
      - main
      - develop

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read

    env:
      ENVIRONMENT: null
      AWS_ACCOUNT_ID: null
      changes_in_deployment_init: false

    steps:
      - name: Checkout repository
        uses: actions/checkout@v2
        with:
          fetch-depth: 2

      - name: Set environment based on branch
        run: |
          if [[ "${GITHUB_REF##*/}" == "main" ]]; then
            echo "ENVIRONMENT=prod" >> $GITHUB_ENV
            echo "AWS_ACCOUNT_ID=${{ secrets.AWS_PROD_ACCOUNT_ID }}" >> $GITHUB_ENV
          elif [[ "${GITHUB_REF##*/}" == "develop" ]]; then
            echo "ENVIRONMENT=dev" >> $GITHUB_ENV
            echo "AWS_ACCOUNT_ID=${{ secrets.AWS_DEV_ACCOUNT_ID }}" >> $GITHUB_ENV
          fi

      - name: Configure AWS credentials for IAM manager
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/terraform-iam-manager
          aws-region: ${{ vars.REGION }}

      - uses: hashicorp/setup-terraform@v3

      - name: Check if there are changes in deployment/init
        id: check-changes-in-deployment-init
        run: |
          cd ${{ github.workspace }}/deployment/init
          if git diff --name-only HEAD^1 HEAD | grep -q '^deployment/init/'; then
            echo "changes_in_deployment_init=true" >> $GITHUB_ENV
          fi

      - name: Create role
        if: env.changes_in_deployment_init == 'true'
        env:
          TF_VAR_account_id: ${{ env.AWS_ACCOUNT_ID }}
        run: |
          cd ${{ github.workspace }}/deployment/init
          terraform init
          terraform apply -auto-approve

      - name: Configure AWS credentials for deploy
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/terraform-exec
          aws-region: ${{ vars.REGION }}

      - name: Deploy infrastructure
        env:
          TF_VAR_account_id: ${{ env.AWS_ACCOUNT_ID }}
          TF_VAR_region: ${{ vars.REGION }}
          TF_VAR_name_prefix: ${{ vars.NAME_PREFIX }}
        run: |
          cd ${{ github.workspace }}/deployment/envs/${{ env.ENVIRONMENT }}
          terraform init
          terraform apply -auto-approve
```

</details>

こちらではまずはterraform-iam-managerというterraform-iam用のロールに切り替えます

```yaml
      - name: Configure AWS credentials for IAM manager
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/terraform-iam-manager
          aws-region: ${{ vars.REGION }}
```

このロールを使って、terraform applyするためのiamロールを作成します

```yaml
      - name: Create role
        if: env.changes_in_deployment_init == 'true'
        env:
          TF_VAR_account_id: ${{ env.AWS_ACCOUNT_ID }}
        run: |
          cd ${{ github.workspace }}/deployment/init
          terraform init
          terraform apply -auto-approve
```

ここで作成されるロールの定義を作ります
/deployement/initにmain.tfを作成します

<details><summary>main.tf</summary>

```tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.16"
    }
  }

  backend "s3" {
    bucket  = "terraform-iam-manager"
    key     = "terraform.tfstate"
    region  = "ap-northeast-1"
    encrypt = true
  }
}

resource "aws_iam_role" "terraform-exec" {
  name = "terraform-exec"
  assume_role_policy = jsonencode({
    "Version" : "2012-10-17",
    "Statement" : [{
      "Sid" : "",
      "Effect" : "Allow",
      "Principal" : {
        "Federated" : "arn:aws:iam::${var.account_id}:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action" : "sts:AssumeRoleWithWebIdentity",
      "Condition" : {
        "StringEquals" : {
          "token.actions.githubusercontent.com:aud" : "sts.amazonaws.com"
        },
        "StringLike" : {
          "token.actions.githubusercontent.com:sub" : [
            "repo:<your-account-name>:<your-repository>:event_name:pull_request_target:base_ref:main:*",
            "repo:<your-account-name>:<your-repository>:event_name:push:base_ref::ref:refs/heads/main"
          ]
        }
      }
    }]
  })
}

resource "aws_iam_policy" "terraform-exec" {
  name        = "terraform-exec"
  description = "Policy for various AWS services"
  policy = jsonencode({
    "Version" : "2012-10-17",
    "Statement" : [
      {
        "Sid" : "VisualEditor0",
        "Effect" : "Allow",
        "Action" : [
          "dynamodb:DeleteItem",
          "dynamodb:DescribeTable",
          "dynamodb:GetItem",
          "dynamodb:PutItem"
        ],
        "Resource" : "*"
      },
      {
        "Sid" : "VisualEditor1",
        "Effect" : "Allow",
        "Action" : [
          "ec2:AssociateRouteTable",
          "ec2:AttachInternetGateway",
          "ec2:CreateInternetGateway",
          "ec2:CreateRoute",
          "ec2:CreateRouteTable",
          "ec2:CreateSubnet",
          "ec2:CreateTags",
          "ec2:CreateVPC",
          "ec2:DeleteInternetGateway",
          "ec2:DeleteRoute",
          "ec2:DeleteRouteTable",
          "ec2:DeleteSubnet",
          "ec2:DeleteTags",
          "ec2:DeleteVPC",
          "ec2:DescribeAccountAttributes",
          "ec2:DescribeInternetGateways",
          "ec2:DescribeNetworkInterfaces",
          "ec2:DescribeRouteTables",
          "ec2:DescribeSubnets",
          "ec2:DescribeVpcAttribute",
          "ec2:DescribeVpcs",
          "ec2:DescribeVpcClassicLink",
          "ec2:DescribeVpcClassicLinkDnsSupport",
          "ec2:DescribeSecurityGroups",
          "ec2:DescribeSecurityGroupReferences",
          "ec2:DetachInternetGateway",
          "ec2:DisassociateRouteTable",
        ],
        "Resource" : "*"
      },
      {
        "Sid" : "VisualEditor4",
        "Effect" : "Allow",
        "Action" : [
          "s3:DeleteObject",
          "s3:GetObject",
          "s3:ListBucket",
          "s3:PutObject"
        ],
        "Resource" : "*"
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "attach_policy" {
  role       = aws_iam_role.terraform-exec.name
  policy_arn = aws_iam_policy.terraform-exec.arn
}

variable "account_id" {}
```

</details>

先ほどのようにOIDCの信頼ポリシーの設定をしています

```tf
      "Condition" : {
        "StringEquals" : {
          "token.actions.githubusercontent.com:aud" : "sts.amazonaws.com"
        },
        "StringLike" : {
          "token.actions.githubusercontent.com:sub" : [
            "repo:<your-account-name>:<your-repository>:event_name:pull_request_target:base_ref:main:*",
            "repo:<your-account-name>:<your-repository>:event_name:push:base_ref::ref:refs/heads/main"
          ]
        }
```

複数条件を入れるとOR条件として判定されます
ここではmainブランチに対するpull_request_targetとpushのみがこのロールを使えます

backendには先ほど作成したterraform-iam用のS3のバケット名を入れます

```tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.16"
    }
  }

  backend "s3" {
    bucket  = "terraform-iam-manager" # ここに作成したバケット名
    key     = "terraform.tfstate"
    region  = "ap-northeast-1"
    encrypt = true
  }
}
```

現状はvpcの作成に必要な権限を与えています
今後リソースを足す場合はこのmain.tfの権限を編集してmainブランチにプッシュすると新しい権限がCreate Roleのステップで付与されます

terraform-execのためのロールが作成されました
次のステップで早速このロールに切り替えています

```yaml
      - name: Configure AWS credentials for deploy
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/terraform-exec
          aws-region: ${{ vars.REGION }}
```

あとはterraform applyするだけです
applyするリソース例として今回はVPCを作成したいと思います

```tf
# deployment/envs/prod/main.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.16"
    }
  }
  backend "s3" {
    bucket         = "terraform-example-bucket" # 作成したバケット名
    key            = "terraform.tfstate"
    region         = "ap-northeast-1"
    encrypt        = true
    dynamodb_table = "terraform-example-terraform" # 作成したdynamoの名前
  }
}

module "network" {
  source = "../../modules/network"

  name_prefix = var.name_prefix
  region      = var.region
}

# Global
variable "name_prefix" {}
variable "region" {}
```

```tf
# deployment/modules/network/main.tf

# ig
resource "aws_internet_gateway" "default" {
  vpc_id = aws_vpc.default.id
}

# rt
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.default.id
}

resource "aws_route" "public" {
  route_table_id         = aws_route_table.public.id
  gateway_id             = aws_internet_gateway.default.id
  destination_cidr_block = "0.0.0.0/0"
}

resource "aws_route_table_association" "public_a" {
  route_table_id = aws_route_table.public.id
  subnet_id      = aws_subnet.public_a.id
}

resource "aws_route_table_association" "public_c" {
  route_table_id = aws_route_table.public.id
  subnet_id      = aws_subnet.public_c.id
}

# subnet
resource "aws_subnet" "public_a" {
  cidr_block        = "10.0.1.0/24"
  vpc_id            = aws_vpc.default.id
  availability_zone = "ap-northeast-1a"
}

resource "aws_subnet" "public_c" {
  cidr_block        = "10.0.2.0/24"
  vpc_id            = aws_vpc.default.id
  availability_zone = "ap-northeast-1c"
}

# vpc
resource "aws_vpc" "default" {
  cidr_block = local.vpc_cidr
  tags = {
    Name = local.tag_name
  }
}

variable "region" {}
variable "name_prefix" {}

locals {
  vpc_cidr = "10.0.0.0/16"
  tag_name = "${var.name_prefix}-vpc"
}

```

## どうやって運用するか

このリポジトリではterraform applyする権限も管理します
なので例えば新規リソースを追加する場合はiam権限を追加するPRとリソースを追加するPRに分ける必要があります

例：ECRを追加したい場合

### Step1

iam権限の追加のPR

```tf
# deployment/init/main.tf

...

      {
        "Sid" : "VisualEditor2",
        "Effect" : "Allow",
        "Action" : [
          "ecr:CreateRepository",
          "ecr:DeleteRepository",
          "ecr:DescribeRepositories",
          "ecr:ListTagsForResource",
          "ecr:PutImageScanningConfiguration"
        ],
        "Resource" : "*"
      }

...
```

### Step2

ECRリソースの追加のPR

```
├──.github
│   └── workflows
│       ├── apply.yml
│       └── plan.yml
│
└── deployment
    ├── envs
    │   └── prod
    │       └── main.tf
    ├── init
    │   └── main.tf
    │   
    └── modules
        ├── ecr # 追加
        │   └── main.tf # 追加
        │
        └── network
            └── main.tf
```

こうすることで一つのリポジトリでインフラ管理を完結できるようになりました
おしまい