🚝

モノレポの GitHub (Enterprise) からプロジェクト(フォルダ)別の AWS CodePipeline を呼び出す

2022/02/20に公開

こちらで紹介されている Lambda 関数を実質的にうまく利用できるものにするための記事です。

GitHub を使っている場合は GitHub Actions を使えば良いのですが、諸事情で CodePipeline を使わざるをえない場合にこちらの方法が使えます。

元記事のコードの問題点

  • 対象ブランチの指定がないのでどこのブランチに push してもパイプラインが実行されてしまう
    • コードの変更以外の操作まで拾うので誤動作の可能性がある
  • モノレポに含まれる複数プロジェクト(複数のフォルダ)にまたがる push が行われた際、1 つのパイプラインしか実行されない

この記事のコードの場合

  • 対象ブランチの指定が可能
  • パイプラインを実行する対象のプロジェクト(フォルダ)を指定可能
  • 複数のプロジェクト(フォルダ)にまたがる push が行われた場合、それぞれのパイプラインを並列で呼び出すことが可能
  • 全プロジェクトから参照されている共有プロジェクト(フォルダ)のようなものがある場合に、全てのパイプラインを並列で呼び出すことが可能

参考

Lambda の構成についてはこちらの記事を参考にしました。

設定手順

1. Secrets Manager にシークレットを保存

GitHub (Enterprise) から API Gateway 経由で Lambda 関数を呼び出す際に使うシークレット(文字列)をパスワードジェネレータなどで生成し、Secrets Manager にGHE_SECRETSというキーで(任意の名前を付けて)保存します。


2. Lambda 関数を作成

以下のコードと環境変数を登録します。

lambda_function.py
import json
import hmac, hashlib
import boto3
import base64
import ast, re
import os
from botocore.exceptions import ClientError

def lambda_handler(event, context):

    body = event['body']
    if is_correct_signature(event['headers']['x-hub-signature'], body):
        print('認証成功')
    project_names = []
    job_name_suffix = os.environ['job_name_suffix']
    body_json = json.loads(body)
    ref = body_json['ref']
    if ref == os.environ['trigger_branch'] and len(body_json['commits']) > 0:
        # 指定ブランチへのコミットの場合だけ処理
        commits = body_json['commits']
        modified_files = []
        for commit in commits:
            modified_files = modified_files + commit['added'] + commit['removed'] + commit['modified']
        print('added / removed / modified : {}'.format(modified_files))
        # どのプロジェクトのビルドを行うかファイルパスから判断
        includes = ['project1', 'project2', 'project3']
        pipelines_count_max = len(includes)
        common = ['common']
        for file_path in modified_files:
            pos = file_path.find('/')
            if pos > 0:
                # パスにフォルダを含む→プロジェクト名を確認
                project_name = file_path[:pos]
                if common.count(project_name) > 0:
                    # 共有プロジェクト名であれば全て呼び出しパイプラインに含める
                    project_names = includes
                    break
                if project_names.count(project_name) == 0:
                    # 対象プロジェクト初検出→呼び出しパイプラインに含める
                        project_names.append(project_name)
                        if len(project_names) == pipelines_count_max:
                            # すべてのプロジェクトを検出→ループを抜ける
                            break
        # 対象プロジェクトをビルドするパイプラインを呼び出す
        print('projects : {}'.format(project_names))
        if len(project_names) > 0:
            for project_name in project_names:
                return_code = start_code_pipeline('{}{}'.format(project_name, job_name_suffix))
                print(return_code)

    return {
        'statusCode': 200,
        'body': json.dumps('Modified project in repo: {}'.format(project_names))
    }
    
def get_secrets_manager_dict(secret_name: str) -> dict:
    """Secrets Managerからシークレットのセットを辞書型で取得する"""
    secrets_dict = {}
    if not secret_name:
        print('シークレットの名前未設定')
    else:
        session = boto3.session.Session()
        client = session.client(
            service_name='secretsmanager',
            region_name='ap-northeast-1'
        )
        try:
            get_secret_value_response = client.get_secret_value(
                SecretId=secret_name
            )
        except ClientError as e:
            print('シークレット取得失敗:シークレットの名前={}'.format(secret_name))
            print(e.response['Error'])
        else:
            if 'SecretString' in get_secret_value_response:
                secret = get_secret_value_response['SecretString']
            else:
                secret = base64.b64decode(get_secret_value_response['SecretBinary'])
            secrets_dict = ast.literal_eval(secret)
    return secrets_dict

def get_secrets_manager_key_value(secret_name: str, secret_key: str) -> str:
    """AWS Secrets Managerからシークレットキーの値を取得する."""
    value = ''
    secrets_dict = get_secrets_manager_dict(secret_name)
    if secrets_dict:
        if secret_key in secrets_dict:
            # secrets_dictが設定されていてsecret_keyがキーとして存在する場合
            value = secrets_dict[secret_key]
        else:
            print('シークレットキーの値取得失敗:シークレットの名前={}、シークレットキー={}'.format(secret_name, secret_key))
    return value

def is_correct_signature(signature: str, body: dict) -> bool:
    """GitHubから送られてきた情報をHMAC認証する."""
    if signature and body:
        # GitHubのWebhookに設定したSecretをSecrets Managerから取得する
        secret = get_secrets_manager_key_value(os.environ['secrets_name'], 'GHE_SECRETS')
        if secret:
            secret_bytes = bytes(secret, 'utf-8')
            body_bytes = bytes(body, 'utf-8')
            # Secretから16進数ダイジェストを作成する
            signedBody = "sha1=" + hmac.new(secret_bytes, body_bytes, hashlib.sha1).hexdigest()
            return signature == signedBody
    else:
        return False

def start_code_pipeline(pipelineName):
    client = codepipeline_client()
    response = client.start_pipeline_execution(name=pipelineName)
    return True

cpclient = None
def codepipeline_client():
    global cpclient
    if not cpclient:
        cpclient = boto3.client('codepipeline')
    return cpclient

※全プロジェクトから参照されている共有プロジェクトのようなものがない場合は、common の定義およびそれを使用するif文のブロックを削除します。

  • job_name_suffix
    • 呼び出すパイプライン名のサフィックス(「【プロジェクト(フォルダ)名】+【サフィックス】」の CodePipeline を呼び出します)
    • 例:-job-XXX
  • secrets_name
    • 先ほど保存したシークレットの名前
    • 例:build-XXX
  • trigger_branch
    • パイプライン実行対象のブランチ名(「refs/heads/【ブランチ名】」)
    • 例:refs/heads/branch_XXX

3. API Gateway を作成し、Lambda 関数を統合

HTTP の API Gateway を作成し、先ほどの Lambda 関数を統合します。

ルートのメソッドはPOSTに限定します。

ステージ名はデフォルトのまま自動デプロイを指定しておきます。

4. Lambda 実行用の IAM Role にポリシーを追加

Lambda 作成時にデフォルトで作成された IAM Role に、以下のポリシーを追加します(インラインポリシーで OK)。

追加するポリシー(シークレット用)
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "secretsmanager:GetResourcePolicy",
                "secretsmanager:GetSecretValue",
                "secretsmanager:DescribeSecret",
                "secretsmanager:ListSecretVersionIds"
            ],
            "Resource": "【シークレットのARN】"
        },
        {
            "Sid": "VisualEditor1",
            "Effect": "Allow",
            "Action": [
                "secretsmanager:GetRandomPassword",
                "secretsmanager:ListSecrets"
            ],
            "Resource": "*"
        }
    ]
}

5. GitHub (Enterprise) で Webhook を設定

先に 3. で作成した API Gateway の URL(+ルートのリソースパス)、1. で生成したシークレット(文字列)を使って Webhook を設定します。

Content type をapplication/jsonに変更します。

6. CodePipeline を設定(Source ステージの設定変更)

Lambda 関数から呼び出す各パイプラインを設定(すでにある場合は変更)します。

※詳細は省略。

Source ステージの以下のチェックを外します。

以上で完成です。

Discussion