モノレポの GitHub (Enterprise) からプロジェクト(フォルダ)別の AWS CodePipeline を呼び出す
こちらで紹介されている Lambda 関数を実質的にうまく利用できるものにするための記事です。
- GitHub モノレポを AWS CodePipeline と統合して、プロジェクト固有の CI/CD パイプラインを実行する(Amazon Web Services ブログ)
GitHub を使っている場合は GitHub Actions を使えば良いのですが、諸事情で CodePipeline を使わざるをえない場合にこちらの方法が使えます。
元記事のコードの問題点
- 対象ブランチの指定がないのでどこのブランチに push してもパイプラインが実行されてしまう
- コードの変更以外の操作まで拾うので誤動作の可能性がある
- モノレポに含まれる複数プロジェクト(複数のフォルダ)にまたがる push が行われた際、1 つのパイプラインしか実行されない
この記事のコードの場合
- 対象ブランチの指定が可能
- パイプラインを実行する対象のプロジェクト(フォルダ)を指定可能
- 複数のプロジェクト(フォルダ)にまたがる push が行われた場合、それぞれのパイプラインを並列で呼び出すことが可能
- 全プロジェクトから参照されている共有プロジェクト(フォルダ)のようなものがある場合に、全てのパイプラインを並列で呼び出すことが可能
参考
Lambda の構成についてはこちらの記事を参考にしました。
- Backlogの課題にGitHubのコミットを連携する方法(ponsuke_tarou’s blog)
設定手順
1. Secrets Manager にシークレットを保存
GitHub (Enterprise) から API Gateway 経由で Lambda 関数を呼び出す際に使うシークレット(文字列)をパスワードジェネレータなどで生成し、Secrets Manager にGHE_SECRETS
というキーで(任意の名前を付けて)保存します。
2. Lambda 関数を作成
以下のコードと環境変数を登録します。
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