Trong bài đăng này bạn sẽ học được cách tạo Lambda trigger cho multiple pipeline với cùng một monorepo. Ở đây sẽ sử dụng Codecommit làm monorepo
Monorepo
Trên thực tế sẽ có bạ đặt ra câu hỏi là tại sao phải sử dụng monorepo, mình cũng không rõ, có lẻ là để các service chia sẽ thư viện của nhau, mình từng gặp một dự án dùng Java và monorepo đấy có tận 12 service(12 bộ source dồn vào một repo), thế mới có khái niệm monorepo
Một vài vấn đề
Nếu mỗi service tách riêng ra và dùng repo riêng (polyrepo) thì mọi chuyện sẽ đơn giản, mỗi repo sẽ có một pipeline riêng, nhưng nếu gộp chung các reop lại thì bài toán đặt ra là làm thế nào để xác định được sự thay đổi code của service nào, để chỉ build lại service đó, thay vì build lại toàn bộ các service trong repo
Hướng giải quyết
Nếu sử dụng gitlab thì gitlab sẽ có cơ chế nhận biết sự thay đổi và trigger theo file, thay vì theo nhánh như codecommit. Giải pháp là kiểm tra sự thay đôi của file sau đó tìm ra thư mục gốc của nó, và build source trong thư mục đó.
Cách 1: Dùng bash script.
Cách này thì mình chưa test, bạn đọc có thể test thử, vì mình đã kiếm được một cách khác hay hơn. Cách này sẽ dùng script để tìm ra sự thay đổi của file và truy ra thư mục gốc, tuy nhiên cách này có nhược điểm là chỉ những micro service trong monorepo có cùng một cách build, và dùng chung một pipeline.
#!/bin/bash -e
ARRAY=( "account:$AD_BUCKET"
"payment:$REGISTER_BUCKET"
"shoping:$MOBILE_BUCKET"
)
SH=$(cd `dirname $BASH_SOURCE` && pwd)
COMMIT_RANGE="HEAD HEAD~1"
changed_folders=`git diff --name-only ${COMMIT_RANGE} | grep / | awk 'BEGIN {FS="/"} {print $1}' | uniq`
for folder in $changed_folders
do
for s3_urls in "${ARRAY[@]}" ; do
FOLDER="${s3_url%%:*}"
S3_URL="${s3_url##*:}"
if [ "$folder" == "$FOLDER" ]; then
eho PRE-INSTALL;
cd $SH/$folder && npm i;
eho BUILD;
npm run build:$ENV;
aws s3 sync --delete ./build $S3_URL
done
done
Đầu tiên mình sẽ tạo một mảng tương ứng giữa tên service (cũng là tên thư mục chứa service trong monorepo) và tên S3 bucket (ở đây mình build Frontend)
Sau đó mình dùng lênh `git diff ` để tìm ra file nào thay đôi và truy ra thư mục gộc của nó, tiếp theo là build và sync đến S3.
Cách 2: Dùng lambda handler event của codecommit.
Cách này sẽ dùng được cho nhiều trường hợp hơn, vì nó không cố định một pipeline. Mỗi khi có commit được push lên, codecommit sẽ trigger lambda, lambda sẽ tiến hành kiểm tra sự thay đổi của file
dựa vào commit id lúc đầu (được lưu ở parameter store) và commit id mới nhất, sau đó truy ra thư mục gốc và trigger pipeline tương ứng.
Kiến trúc monorepo
Trong ví dụ này, mình sẽ tạo ra hai thư mục là service_1 và service_2. tương ứng với pipeline codepipeline-service_1 và codepipeline-service_2 ở nhánh main, trong thư mục sẽ có một vài code HTML đơn giản.
.
├── README.md
├── monorepo-main.json
├── service_1
│ ├── error.html
│ └── index.html
└── service_2
├── error.html
└── index.html
2 directories, 6 files
bạn có thể mở rộng số service và số pipeline, vì cấu hình không được fix cứng và có thể thay đổi thông qua file monorepo-main.json
{
"service_1": "codepipeline-service_1",
"service_2": "codepipeline-service_2"
}
nếu bạn muốn tạo một nhánh khác thì chỉ đơn giản là đổi monorepo-main.json thành monorepo-.json tương ứng.
Code Lambda
Source code mình để ở đây
Mình đã tạo sẳn Cloudformation template, việc bạn cần làm là tạo một S3 bucket chứa file lambda.zip (file này chứa code lambda, bạn có thể giaỉ nén ra để xem code), khi lambda chạy nó sẽ đọc code từ bucket này.
import logging
import base64
import boto3
import botocore
from functools import reduce
import json
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
codecommit = boto3.client('codecommit')
ssm = boto3.client('ssm')
def main(event, context):
"""
This AWS Lambda is triggered by AWS CodeCommit event.
It starts AWS CodePipelines according to modifications in toplevel folders of the monorepo.
Each toplevel folder can be associate with a different AWS CodePipeline.
Must be run without concurrency - once at time for each monorepo (to avoid race condition while updating last commit parameter in SSM ParameterStore)
"""
logger.info('event: %s', event)
commit_id = get_commit_id(event)
branch_name = get_branch_name(event)
# logger.info('references: %s', references)
repository = event['Records'][0]['eventSourceARN'].split(':')[5]
paths = get_modified_files_since_last_run(
repositoryName=repository, afterCommitSpecifier=commit_id, branch_name=branch_name)
logger.info('paths: %s', paths)
print(paths)
toplevel_dirs = get_unique_toplevel_dirs(paths)
print("unique toplevl dirs:", toplevel_dirs)
pipeline_names = resolve_pipeline_names(toplevel_dirs, repository, branch_name)
print("pipeline_names:", pipeline_names)
print(start_codepipelines(pipeline_names))
update_last_commit(repository, commit_id, branch_name)
def get_commit_id(event):
return event['Records'][0]['codecommit']['references'][0]['commit']
def get_branch_name(event):
branch_ref = event['Records'][0]['codecommit']['references'][0]['ref']
return branch_ref.split('/')[-1]
def resolve_pipeline_names(toplevel_dirs, repository, branch_name):
"""
Look up for pipeline names according to the toplevel dir names.
File name with the mapping (folder -> codepipeline-name) must be in the root level of the repo.
Returns CodePipeline names that need to be triggered.
"""
pipeline_map = codecommit.get_file(repositoryName=repository,
commitSpecifier=f'refs/heads/{branch_name}', filePath=f'monorepo-{branch_name}.json')['fileContent']
pipeline_map = json.loads(pipeline_map)
pipeline_names = []
for dir in toplevel_dirs:
if dir in pipeline_map:
pipeline_names.append(pipeline_map[dir])
return pipeline_names
def get_unique_toplevel_dirs(modified_files):
"""
Returns toplevel folders that were modified by the last commit(s)
"""
toplevel_dirs = set(
[splitted[0] for splitted in (file.split('/') for file in modified_files)
if len(splitted) > 1]
)
logger.info('toplevel dirs: %s', toplevel_dirs)
return toplevel_dirs
def start_codepipelines(codepipeline_names: list) -> dict:
"""
start CodePipeline (s)
Returns a tupple with 2 list: (success_started_pipelines, failed_to_start_pipelines)
"""
codepipeline_client = boto3.Session().client('codepipeline')
failed_codepipelines = []
started_codepipelines = []
for codepipeline_name in codepipeline_names:
try:
codepipeline_client.start_pipeline_execution(
name=codepipeline_name
)
logger.info(f'Started CodePipeline {codepipeline_name}.')
started_codepipelines.append(codepipeline_name)
except codepipeline_client.exceptions.PipelineNotFoundException:
logger.info(f'Could not find CodePipeline {codepipeline_name}.')
failed_codepipelines.append(codepipeline_name)
return (started_codepipelines, failed_codepipelines)
def build_parameter_name(repository, branch_name):
"""
Create the name of SSM ParameterStore LastCommit
"""
# TODO must have the branch name in the parameter?
return f'/MonoRepoTrigger/{repository}/{branch_name}/LastCommit'
def get_last_commit(repository, commit_id, branch_name):
"""
Get last triggered commit id.
Strategy: try to find the last commit id in SSM Parameter Store '/MonoRepoTrigger/{repository}/LastCommit',
if does not exist, get the parent commit from the commit that triggers this lambda
Return last triggered commit hash
"""
param_name = build_parameter_name(repository, branch_name)
try:
return ssm.get_parameter(Name=param_name)['Parameter']['Value']
except botocore.exceptions.ClientError:
logger.info('not found ssm parameter %s', param_name)
commit = codecommit.get_commit(
repositoryName=repository, commitId=commit_id)['commit']
parent = None
if commit['parents']:
parent = commit['parents'][0]
return parent
def update_last_commit(repository, commit_id, branch_name):
"""
Update '/MonoRepoTrigger/{repository}/LastCommit' SSM Parameter Store with the current commit that triggered the lambda
"""
ssm.put_parameter(Name=build_parameter_name(repository, branch_name),
Description='Keep track of the last commit already triggered',
Value=commit_id,
Type='String',
Overwrite=True)
def get_modified_files_since_last_run(repositoryName, afterCommitSpecifier, branch_name):
"""
Get all modified files since last time the lambda was triggered. Developer can push several commit at once,
so the number of commits between beforeCommit and afterCommit can be greater than one.
"""
last_commit = get_last_commit(repositoryName, afterCommitSpecifier, branch_name)
print("last_commit: ", last_commit)
print("commit_id: ", afterCommitSpecifier)
# TODO working with next_token to paginate
diff = None
if last_commit:
diff = codecommit.get_differences(repositoryName=repositoryName, beforeCommitSpecifier=last_commit,
afterCommitSpecifier=afterCommitSpecifier)['differences']
else:
diff = codecommit.get_differences(repositoryName=repositoryName,
afterCommitSpecifier=afterCommitSpecifier)['differences']
logger.info('diff: %s', diff)
before_blob_paths = {d.get('beforeBlob', {}).get('path') for d in diff}
after_blob_paths = {d.get('afterBlob', {}).get('path') for d in diff}
all_modifications = before_blob_paths.union(after_blob_paths)
return filter(lambda f: f is not None, all_modifications)
if __name__ == '__main__':
main({'Records': [{'codecommit': {'references': [{'commit': 'a6528d2dd877288e7c0ebdf9860d356e6d4bd073',
}]}, 'eventSourceARN': ':::::repo-test-trigger-lambda'}]}, {})
Tên bucket này bạn sẽ truyền vào template thông qua param ở template-master.yaml
LambdaZipS3Bucket:
Default: 'lambda-codecommit-monorepo-build-trigger'
Type: String
LambdaZipS3Key:
Default: 'lambda.zip'
Type: String
Deploy template
Bạn cần tạo một bucket để lưu template cho việc deploy cloudformation. dưới đây là cấu trúc repo, mình sẻ chia nhỏ các resoirce thành các template khác nhau và kết nối chúng lại bằng template master.
.
├── README.md
├── cicd-pipeline.yaml
├── deploy-s3.yaml
├── doc
│ └── architecture.png
├── lambda-event-handler.yaml
├── lambda.zip
└── template-master.yaml
1 directory, 7 files
Tên của bucket này sẽ được dùng ở template-master.yaml
.
Templates:
Type: String
Default: template-demo-lambda-trigger
Description: Bucket stores the cloudformation template for system deployment.
Sau đó bạn click vào template-master.yaml, copy link của nó.
Và tiến hành khởi tạo cloudformation bằng link template vừa copy.
sau đó nhấn Create
Testing
Sau khi deploy xong thì tiến hành kiểm tra thôi, mình sẽ tạo một commit bất kì. Kiểm tra pipeline hoạt động ngon lành.