CDK for TerraformでGithub Actionsを使ったCDKデプロイパイプラインを作ってみた

2022.11.22

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

CX事業部Delivery部の新澤です。

CDKのデプロイをGithub Actionsで実行するパイプラインを作成するにあたって、AWS側とGithub側、両方のリソース作成をコードでいっぺんに終わらせてしまいたかったので、せっかくなので2022年8月にGAされたCDK for Terraform(CDKTF)を使って試してみました。

やってみた

作成するリソースは以下になります。

  • Github
    • Githubリポジトリ
    • Github Actions シークレット
  • AWS
    • IAM IDプロバイダー
    • IAM Role

準備

CDKTFの初期化を実行します。

いくつかの質問に回答すると、依存ライブラリのインストールが開始されます。

$ cdktf init --template=typescript --local
Note: By supplying '--local' option you have chosen local storage mode for storing the state of your stack.
This means that your Terraform state file will be stored locally on disk in a file 'terraform.<STACK NAME>.tfstate' in the root of your project.
? Project Name cdktf-github-actions-cicd-sample
? Project Description A simple getting started project for cdktf.
? Do you want to start from an existing Terraform project? No
? Do you want to send crash reports to the CDKTF team? See https://www.terraform.io/cdktf/create-and-deploy/configuration-file#enable-crash-reporting-for-the-cli for more information Yes

added 2 packages, and audited 57 packages in 576ms

5 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

added 297 packages, and audited 354 packages in 2s

33 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
========================================================================================================

  Your cdktf typescript project is ready!

  cat help                Print this message

  Compile:
    npm run get           Import/update Terraform providers and modules (you should check-in this directory)
    npm run compile       Compile typescript code to javascript (or "npm run watch")
    npm run watch         Watch for changes and compile typescript in the background
    npm run build         Compile typescript

  Synthesize:
    cdktf synth [stack]   Synthesize Terraform resources from stacks to cdktf.out/ (ready for 'terraform apply')

  Diff:
    cdktf diff [stack]    Perform a diff (terraform plan) for the given stack

  Deploy:
    cdktf deploy [stack]  Deploy the given stack

  Destroy:
    cdktf destroy [stack] Destroy the stack

  Test:
    npm run test        Runs unit tests (edit __tests__/main-test.ts to add your own tests)
    npm run test:watch  Watches the tests and reruns them on change

  Upgrades:
    npm run upgrade        Upgrade cdktf modules to latest version
    npm run upgrade:next   Upgrade cdktf modules to latest "@next" version (last commit)

 Use Providers:

  You can add prebuilt providers (if available) or locally generated ones using the add command:
  
  cdktf provider add "aws@~>3.0" null kreuzwerker/docker

  You can find all prebuilt providers on npm: https://www.npmjs.com/search?q=keywords:cdktf
  You can also install these providers directly through npm:

  npm install @cdktf/provider-aws
  npm install @cdktf/provider-google
  npm install @cdktf/provider-azurerm
  npm install @cdktf/provider-docker
  npm install @cdktf/provider-github
  npm install @cdktf/provider-null

  You can also build any module or provider locally. Learn more https://cdk.tf/modules-and-providers

ライブラリのインストールが終わるとインストールするTerraformプロバイダーの選択肢が現れます。

今回は、aws, github, tlsの3つを選択します。

? What providers do you want to use? aws, github, tls
Checking whether pre-built provider exists for the following constraints:
  provider: aws
  version : latest
  language: typescript
  cdktf   : 0.14.1

Found pre-built provider.
Adding package @cdktf/provider-aws @ 11.0.0
Installing package @cdktf/provider-aws @ 11.0.0 using npm.
Package installed.
Checking whether pre-built provider exists for the following constraints:
  provider: integrations/github
  version : latest
  language: typescript
  cdktf   : 0.14.1

Found pre-built provider.
Adding package @cdktf/provider-github @ 4.0.0
Installing package @cdktf/provider-github @ 4.0.0 using npm.
Package installed.
Checking whether pre-built provider exists for the following constraints:
  provider: tls
  version : latest
  language: typescript
  cdktf   : 0.14.1

Found pre-built provider.
Adding package @cdktf/provider-tls @ 4.0.0
Installing package @cdktf/provider-tls @ 4.0.0 using npm.
Package installed.

$ tree -L 1
\.
├── __tests__
├── cdktf.json
├── help
├── jest.config.js
├── main.ts
├── node_modules
├── package-lock.json
├── package.json
├── setup.js
└── tsconfig.json

CDKの作成

まず、最初にIAM IDプロバイダーを作成します。Github ActionsからOIDC認証したいのでOIDC用IDプロバイダーを作成してみます。

lib/aws-oidc-provider.ts

import { IamOpenidConnectProvider } from "@cdktf/provider-aws/lib/iam-openid-connect-provider";
import { dataTlsCertificate } from "@cdktf/provider-tls";
import { TlsProvider } from "@cdktf/provider-tls/lib/provider";
import { Construct } from "constructs";

export class AwsOidcProvider extends Construct {
    readonly oidcProviderArn: string;
    constructor(scope: Construct, id: string) {
        super(scope, id);

        // TLS Provider
        new TlsProvider(this, 'TLS');

        // IAM OIDC Provider
        const githubCert = new dataTlsCertificate.DataTlsCertificate(this, 'GithubCertificate', {
            url: 'https://token.actions.githubusercontent.com/.well-known/openid-configuration',
        });
        const oidcProvider = new IamOpenidConnectProvider(this, 'AwsOidcProvider', {
            url: 'https://token.actions.githubusercontent.com',
            clientIdList: ["sts.amazonaws.com"],
            thumbprintList: [githubCert.certificates.get(0).sha1Fingerprint],
        });
        this.oidcProviderArn = oidcProvider.arn;
    }
}

OIDC認証用IDプロバイダーの作成時にOIDCプロバイダのCAの証明書のハッシュ値(thumbprint)を指定する必要があるのですが、こちらの記事で紹介されているTerraformの実装を参考にCDKTFに書き換えて実装しています。

const githubCert = new dataTlsCertificate.DataTlsCertificate(this, 'GithubCertificate', {
    url: 'https://token.actions.githubusercontent.com/.well-known/openid-configuration',
});
const oidcProvider = new IamOpenidConnectProvider(this, 'AwsOidcProvider', {
    url: 'https://token.actions.githubusercontent.com',
    clientIdList: ["sts.amazonaws.com"],
    thumbprintList: [githubCert.certificates.get(0).sha1Fingerprint],
});

次はリポジトリと、リポジトリに対応するIAM Role、Role ARNを格納するGithub Actionsシークレットを作ります。 こちらは、リポジトリ毎に複数作成することもあるかと思い、コンストラクトにまとめてみました。

IAM Roleの設定内容は、Github公式ドキュメントの内容に沿ったものにしています。

lib/github-repository-with-actions-aws-oidc.ts

import { DataAwsIamPolicyDocument } from "@cdktf/provider-aws/lib/data-aws-iam-policy-document";
import { IamRole } from "@cdktf/provider-aws/lib/iam-role";
import { ActionsSecret } from "@cdktf/provider-github/lib/actions-secret";
import { Repository } from "@cdktf/provider-github/lib/repository";
import { Construct } from "constructs";

type GithubRepositoryWithActionsAwsOidcProps = {
    oidcProviderArn: string,
    repositoryName: string,
    repositoryDescription: string,
    repositoryBranchNane: string,
    githubActionsSecretName?: string,
}

export class GithubRepositoryWithActionsAwsOidc extends Construct {
    constructor(scope: Construct, id: string, props: GithubRepositoryWithActionsAwsOidcProps) {
        super(scope, id);

        // Github Repository
        const repository = new Repository(this, 'Repository', {
            name: props.repositoryName,
            description: props.repositoryDescription,
            visibility: 'private'
        })

        // IAM Role
        const assumeRolePolicyDoc = new DataAwsIamPolicyDocument(this, 'AwsOidcAssumeRolePolicy', {
            statement: [{
                effect: 'Allow',
                principals: [
                    {
                        type: "Federated",
                        identifiers: [ props.oidcProviderArn ],
                    }
                ],
                actions: ['sts:AssumeRoleWithWebIdentity'],
                condition: [
                    {
                        test: 'StringEquals',
                        variable: 'token.actions.githubusercontent.com:aud',
                        values: ['sts.amazonaws.com']
                    },
                    {
                        test: 'StringEquals',
                        variable: 'token.actions.githubusercontent.com:sub',
                        values: [`repo:${repository.fullName}:ref:refs/heads/${props.repositoryBranchNane}`]
                    },
                ]
            }],
        });
        const oidcRole = new IamRole(this, 'AwsOidcRole', {
            name: 'sample-github-actions-role',
            assumeRolePolicy: assumeRolePolicyDoc.json,
            managedPolicyArns: ['arn:aws:iam::aws:policy/AdministratorAccess'],
        });

        // Github Actions Secret
        new ActionsSecret(this, 'ActionsEnvSecret', {
            repository: repository.name,
            secretName: props.githubActionsSecretName || 'AWS_OIDC_ROLE_ARN',
            plaintextValue: oidcRole.arn,
        });
    }
}

最後に上記をmain.tsで呼び出します。

実行時に必要なAWSクレデンシャル情報とGithubトークンは、環境変数で提供する想定にしています。

main.ts

import { AwsProvider } from "@cdktf/provider-aws/lib/provider";
import { GithubProvider } from "@cdktf/provider-github/lib/provider";
import { App, TerraformStack } from "cdktf";
import { Construct } from "constructs";
import { AwsOidcProvider } from "./lib/aws-oidc-provider";
import { GithubRepositoryWithActionsAwsOidc } from "./lib/github-repository-with-actions-aws-oidc";

class GithubActionsCDKPipeline extends TerraformStack {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    // Github Provider
    new GithubProvider(this, "Github", {
      token: process.env.GITHUB_TOKEN,
    });

    // AWS Provider
    new AwsProvider(this, "AWS");

    const oidcProvider = new AwsOidcProvider(this, "AwsOidcProvider");

    new GithubRepositoryWithActionsAwsOidc(this, 'Resources', {
      repositoryName: 'sample-repo',
      repositoryDescription: 'sample repository',
      repositoryBranchNane: 'main',
      oidcProviderArn: oidcProvider.oidcProviderArn,
    });

  }
}

const app = new App();
new GithubActionsCDKPipeline(app, "cdktf-github-actions-cicd-sample");
app.synth();

デプロイ

それでは、作成したCDKスタックをデプロイしてみます。

$ export GITHUB_TOKEN='xxxxxxxxxxxxxxxxxx'
$ export | grep AWS_
AWS_ACCESS_KEY_ID=xxxxxxxxxxxxxxx
AWS_DEFAULT_REGION=ap-northeast-1
AWS_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxx

$ cdktf deploy
cdktf-github-actions-cicd-sample  Initializing the backend...
cdktf-github-actions-cicd-sample  Initializing provider plugins...
                                  - Reusing previous version of hashicorp/aws from the dependency lock file
cdktf-github-actions-cicd-sample  - Reusing previous version of integrations/github from the dependency lock file
cdktf-github-actions-cicd-sample  - Reusing previous version of hashicorp/tls from the dependency lock file
cdktf-github-actions-cicd-sample  - Using previously-installed hashicorp/aws v4.39.0
cdktf-github-actions-cicd-sample  - Using previously-installed integrations/github v4.31.0
cdktf-github-actions-cicd-sample  - Using previously-installed hashicorp/tls v4.0.4
cdktf-github-actions-cicd-sample  Terraform has been successfully initialized!
                                  
                                  You may now begin working with Terraform. Try running "terraform plan" to see
                                  any changes that are required for your infrastructure. All Terraform commands
                                  should now work.

                                  If you ever set or change modules or backend configuration for Terraform,
                                  rerun this command to reinitialize your working directory. If you forget, other
                                  commands will detect it and remind you to do so if necessary.
cdktf-github-actions-cicd-sample  data.tls_certificate.AwsOidcProvider_GithubCertificate_1922994C (AwsOidcProvider/GithubCertificate): Reading...
cdktf-github-actions-cicd-sample  data.tls_certificate.AwsOidcProvider_GithubCertificate_1922994C (AwsOidcProvider/GithubCertificate): Read complete after 0s [id=2f98b9dddcf0778622dc6788373a7f8c02e3a2c3]
cdktf-github-actions-cicd-sample  Terraform used the selected providers to generate the following execution
                                  plan. Resource actions are indicated with the following symbols:
                                    + create
                                   <= read (data resources)
                                  
                                  Terraform will perform the following actions:
cdktf-github-actions-cicd-sample    # data.aws_iam_policy_document.Resources_AwsOidcAssumeRolePolicy_BF028658 (Resources/AwsOidcAssumeRolePolicy) will be read during apply
                                    # (config refers to values not yet known)
                                   <= data "aws_iam_policy_document" "Resources_AwsOidcAssumeRolePolicy_BF028658" {
                                        + id   = (known after apply)
                                        + json = (known after apply)

                                        + statement {
                                            + actions = [
                                                + "sts:AssumeRoleWithWebIdentity",
                                              ]
                                            + effect  = "Allow"

                                            + condition {
                                                + test     = "StringEquals"
                                                + values   = [
                                                    + "sts.amazonaws.com",
                                                  ]
                                                + variable = "token.actions.githubusercontent.com:aud"
                                              }
                                            + condition {
                                                + test     = "StringEquals"
                                                + values   = [
                                                    + (known after apply),
                                                  ]
                                                + variable = "token.actions.githubusercontent.com:sub"
                                              }

                                            + principals {
                                                + identifiers = [
                                                    + (known after apply),
                                                  ]
                                                + type        = "Federated"
                                              }
                                          }
                                      }

                                    # aws_iam_openid_connect_provider.AwsOidcProvider_F379C144 (AwsOidcProvider/AwsOidcProvider) will be created
                                    + resource "aws_iam_openid_connect_provider" "AwsOidcProvider_F379C144" {
                                        + arn             = (known after apply)
                                        + client_id_list  = [
                                            + "sts.amazonaws.com",
                                          ]
                                        + id              = (known after apply)
                                        + tags_all        = (known after apply)
                                        + thumbprint_list = [
                                            + "6938fd4d98bab03faadb97b34396831e3780aea1",
                                          ]
                                        + url             = "https://token.actions.githubusercontent.com"
                                      }

                                    # aws_iam_role.Resources_AwsOidcRole_8745526F (Resources/AwsOidcRole) will be created
                                    + resource "aws_iam_role" "Resources_AwsOidcRole_8745526F" {
                                        + arn                   = (known after apply)
                                        + assume_role_policy    = (known after apply)
                                        + create_date           = (known after apply)
                                        + force_detach_policies = false
                                        + id                    = (known after apply)
                                        + managed_policy_arns   = [
                                            + "arn:aws:iam::aws:policy/AdministratorAccess",
                                          ]
                                        + max_session_duration  = 3600
                                        + name                  = "sample-github-actions-role"
                                        + name_prefix           = (known after apply)
                                        + path                  = "/"
                                        + tags_all              = (known after apply)
                                        + unique_id             = (known after apply)

                                        + inline_policy {
                                            + name   = (known after apply)
                                            + policy = (known after apply)
                                          }
                                      }

                                    # github_actions_secret.Resources_ActionsEnvSecret_522D50A7 (Resources/ActionsEnvSecret) will be created
                                    + resource "github_actions_secret" "Resources_ActionsEnvSecret_522D50A7" {
                                        + created_at      = (known after apply)
                                        + id              = (known after apply)
                                        + plaintext_value = (sensitive value)
                                        + repository      = "sample-repo"
                                        + secret_name     = "AWS_OIDC_ROLE_ARN"
                                        + updated_at      = (known after apply)
                                      }

                                    # github_repository.Resources_Repository_0F8A5956 (Resources/Repository) will be created
                                    + resource "github_repository" "Resources_Repository_0F8A5956" {
                                        + allow_auto_merge            = false
                                        + allow_merge_commit          = true
                                        + allow_rebase_merge          = true
                                        + allow_squash_merge          = true
                                        + archived                    = false
                                        + branches                    = (known after apply)
                                        + default_branch              = (known after apply)
                                        + delete_branch_on_merge      = false
                                        + description                 = "sample repository"
                                        + etag                        = (known after apply)
                                        + full_name                   = (known after apply)
                                        + git_clone_url               = (known after apply)
                                        + html_url                    = (known after apply)
                                        + http_clone_url              = (known after apply)
                                        + id                          = (known after apply)
                                        + merge_commit_message        = "PR_TITLE"
                                        + merge_commit_title          = "MERGE_MESSAGE"
                                        + name                        = "sample-repo"
                                        + node_id                     = (known after apply)
                                        + private                     = (known after apply)
                                        + repo_id                     = (known after apply)
                                        + squash_merge_commit_message = "COMMIT_MESSAGES"
                                        + squash_merge_commit_title   = "COMMIT_OR_PR_TITLE"
                                        + ssh_clone_url               = (known after apply)
                                        + svn_url                     = (known after apply)
                                        + visibility                  = "private"
                                      }

                                  Plan: 4 to add, 0 to change, 0 to destroy.
                                  
                                  ─────────────────────────────────────────────────────────────────────────────

                                  Saved the plan to: plan

                                  To perform exactly these actions, run the following command to apply:
                                      terraform apply "plan"

Please review the diff output above for cdktf-github-actions-cicd-sample
❯ Approve  Applies the changes outlined in the plan.
  Dismiss
  Stop

特に問題が無ければ、Approveを選択します。

cdktf-github-actions-cicd-sample  github_repository.Resources_Repository_0F8A5956 (Resources/Repository): Creating...
cdktf-github-actions-cicd-sample  aws_iam_openid_connect_provider.AwsOidcProvider_F379C144 (AwsOidcProvider/AwsOidcProvider): Creating...
cdktf-github-actions-cicd-sample  aws_iam_openid_connect_provider.AwsOidcProvider_F379C144 (AwsOidcProvider/AwsOidcProvider): Creation complete after 1s [id=arn:aws:iam::xxxxxxxxxxxx:oidc-provider/token.actions.githubusercontent.com]
cdktf-github-actions-cicd-sample  github_repository.Resources_Repository_0F8A5956 (Resources/Repository): Creation complete after 6s [id=sample-repo]
cdktf-github-actions-cicd-sample  data.aws_iam_policy_document.Resources_AwsOidcAssumeRolePolicy_BF028658 (Resources/AwsOidcAssumeRolePolicy): Reading...
cdktf-github-actions-cicd-sample  data.aws_iam_policy_document.Resources_AwsOidcAssumeRolePolicy_BF028658 (Resources/AwsOidcAssumeRolePolicy): Read complete after 0s [id=1632601266]
cdktf-github-actions-cicd-sample  aws_iam_role.Resources_AwsOidcRole_8745526F (Resources/AwsOidcRole): Creating...
cdktf-github-actions-cicd-sample  aws_iam_role.Resources_AwsOidcRole_8745526F (Resources/AwsOidcRole): Creation complete after 2s [id=sample-github-actions-role]
cdktf-github-actions-cicd-sample  github_actions_secret.Resources_ActionsEnvSecret_522D50A7 (Resources/ActionsEnvSecret): Creating...
cdktf-github-actions-cicd-sample  github_actions_secret.Resources_ActionsEnvSecret_522D50A7 (Resources/ActionsEnvSecret): Creation complete after 2s [id=sample-repo:AWS_OIDC_ROLE_ARN]
cdktf-github-actions-cicd-sample  
                                  Apply complete! Resources: 4 added, 0 changed, 0 destroyed.
                                  

No outputs found.

デプロイできました!

CDKデプロイパイプラインを動かしてみる

作成したリポジトリを使ってCDKデプロイしてみます。

こちらはCDKTFではなく、AWS CDK v2を使用したものになります。

内容はS3バケットを1つデプロイするだけの簡単なスタックです。

import * as cdk from 'aws-cdk-lib';
import { Bucket } from 'aws-cdk-lib/aws-s3';
import { Queue } from 'aws-cdk-lib/aws-sqs';
import { Construct } from 'constructs';

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

    new Bucket(this, 'SampleBAwsCdkGithubActionsCicdSampleBucket', {
      bucketName: `sample-bucket-${this.account}`,
      removalPolicy: RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });

  }
}

Github Actionsの定義は以下のようにしました。

.github/workflows/cd.yaml

on:
  push:
    branches:
      - main
    paths:
      - 'bin/**'
      - 'lib/**'

permissions:
  id-token: write
  contents: read

env:
  AWS_OIDC_ROLE_ARN: ${{ secrets.AWS_OIDC_ROLE_ARN }}
  AWS_REGION: ap-northeast-1

jobs:
  aws-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Assume Role
        uses: aws-actions/configure-aws-credentials@v1-node16
        with:
          role-to-assume: ${{ env.AWS_OIDC_ROLE_ARN }}
          aws-region: ${{env.AWS_REGION}}

      - name: Cache CDK Dependency
        uses: actions/cache@v3
        id: cache_cdk_dependency_id
        env:
          cache-name: cache-cdk-dependency
        with:
          path: node_modules
          key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('package-lock.json') }}
          restore-keys: ${{ runner.os }}-build-${{ env.cache-name }}-

      - name: Install CDK Dependency
        if: ${{ steps.cache_cdk_dependency_id.outputs.cache-hit != 'true' }}
        run: npm install

      - name: Deploy
        run: npm run deploy

CDKスクリプトを先ほど作成したリポジトリにプッシュして、動作を確認してみます。

Github Actionsが正常に実行されました。

S3バケットも作成されています。

$ aws s3 ls | grep sample-bucket                        
2022-11-22 16:48:48 sample-bucket-xxxxxxxxxxx

最後に

GithubリポジトリからGithub ActionsでCDKデプロイを行うパイプラインを作成する際に必要な設定をCDK for Terraformを使って一気にやってみました。

AWS以外のリソースも含めてコードで管理しようとするとTerraformを利用することが多いと思いますが、私のようにTerraformにあまり馴染みがないけどAWS CDKは普段から活用しているというような場合には、CDK for Terraformは非常にとっつきやすいのではないでしょうか?