Backlog と連携して Lambda で git clone してコードを S3 に配置する

Backlog の GIT が AWS の Codepipeline と直接連携出来ないので Webhook ApiGateway Lambda S3 を使用する事で連携出来るようにします。

ここでは下記 2 点の設定を記載します。

  1. Serverless Framework で ApiGateway Lambda のデプロイ
  2. Backlog の Webhook の設定

参考

流れ

  1. Backlog の GIT にプッシュする。
  2. Backlog の Webhook が ApiGateway にリクエストを送る。
  3. ApiGateway が Lambda を実行する。
  4. Lambda が git clone を実行し S3 にコードを配置する。

前提

  1. awscli が設定済み。
  2. S3 バケットが作成済み。
  3. KMS でマスターキーを作成済み。
  4. serverless をインストール済み。

階層

serverless で作成したプロジェクト直下は下記のとおりです。

.
├── README.md
├── add_encrypt_password.py
├── handler.py
└── serverless.yml

設定手順

  1. シークレットファイルを作成する
  2. 暗号化したパスワードを追加する
  3. デプロイする
  4. レイヤーを追加する
  5. Backlog の Webhook を設定する

シークレットファイルを作成する

ファイル .secrets.yml をプロジェクト直下に作り下記の通り設定します。
このファイルの値を使用して暗号化と環境変数を設定します。

arn: arn:aws:kms:ap-northeast-1:xxxxxxxxxxxx:key/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
bucket_name: bucket_name
plain_password: password
repository_url: https://subdomain.backlog.com/git/PROJECT/repository-name.git
target_branches: '["master","develop"]'
user_name: firstname.lastname@example.com
  • arn: KMS の ARN の値を設定する。
  • bucket_name: コードを配置したい S3 のバケット名を設定する。
  • plain_password: GIT のパスワードを設定する。
  • repository_url: GIT のリポジトリの URL を設定する。
  • target_branches: プッシュされた時に S3 に入れたいブランチ名を設定する。
  • user_name: GIT のユーザー名を設定する。

暗号化したパスワードを追加する

ファイル add_encrypt_password.py を実行します。
ファイル .secrets.yml に password 属性で GIT のパスワードを暗号化した値を追加します。

python add_encrypt_password.py

ローカル環境で実行する

ローカル環境にて実行するのコマンドは下記の通りです。

sls invoke local --function main --data '{"body": {"content": {"ref": "refs/heads/master"}}}'

オプション「--data」の引数でリクエストの body を擬似的に取得しています。
テストなので最低限必要な JSON のみで master ブランチがプッシュされたとして記載しています。

デプロイする

Lambda にデプロイするコマンドは下記の通りです。

sls deploy

レイヤーを追加する

Lambda で git コマンドが使えるようにレイヤーを追加します。

  1. このページの「Version ARNs for Amazon Linux 2 runtimes」の「ARN」をコピーする。
  2. Lambda のページを開く。
  3. 対象の関数をクリックする。
  4. 「関数概要」の図の中の「Layers (0)」をクリックする。
  5. 項目「レイヤー」の「レイヤーの追加」をクリックする。
  6. 「RAN を指定」をクリックする。
  7. 手順 1 でコピーした値をテキストボックスにペーストする。
  8. ペーストした値のリージョンを該当するリージョンに書き換える。
  9. 「追加」をクリックする。

※ 手順 8 の値の例。

arn:aws:lambda:ap-northeast-1:553035198032:layer:git-lambda2:8

Backlog の Webhook を設定する

  1. 左サイドバーの「プロジェクトの設定」をクリックする。
  2. 「インテグレーション」をクリックする。
  3. 「Webhook」の「設定」をクリックする。
  4. 「設定」タブの「Webhook を追加する」をクリックする。
  5. 下記の設定をする。
    • Webhook 名: put_source_into_s3
    • 説明: ApiGateway -> lambda -> s3 と言う流れでソースコードを s3 に保存する。
    • WebHook URL: [ApiGateway のエンドポイント]
    • 「通知するイベント」の「Git プッシュ」にチェックを付ける。
    • 「保存」をクリックする。

コード

add_encrypt_password.py

import base64
import boto3
import yaml

kms = boto3.client("kms")

class Encrypt:
    """
    secrets.yml の plain_password を暗号化し、password 属性として追加する。
    """
    secrets = {}
    arn = None
    plain_password = None
    encrypted_password = None
    decoded_password = None

    def __init__(self):
        """
        secrets.yml を読み込み arn と plain_password を設定する。
        """
        with open("./.secrets.yml", "r") as yml:
            self.secrets = yaml.safe_load(yml)
            self.arn = self.secrets.get("arn", None)
            self.plain_password = self.secrets.get("plain_password", None)

    def create_encrypted_password(self):
        """
        パスワードを暗号化する。
        """
        response = kms.encrypt(KeyId=self.arn, Plaintext=self.plain_password)
        self.encrypted_password = response["CiphertextBlob"]

    def create_decoded_password(self):
        """
        (暗号化された)パスワードをデコードする。
        """
        self.decoded_password = base64.b64encode(self.encrypted_password).decode(
            "utf-8"
        )

    def write_encrypted_password(self):
        """
        (暗号化された)パスワードを password 属性として追加する。
        """
        self.secrets["password"] = self.decoded_password
        with open("./.secrets.yml", "w") as f:
            yaml.dump(self.secrets, f)

encrypt = Encrypt()
encrypt.create_encrypted_password()
encrypt.create_decoded_password()
encrypt.write_encrypted_password()

serverless.yml

service: put-source-into-s3

frameworkVersion: "3"

custom:
  kmsSecrets:
    file: ${file(./.secrets.yml)}

provider:
  name: aws
  stage: any
  runtime: python3.9
  region: ap-northeast-1

  apiGateway:
    resourcePolicy:
      - Effect: Allow
        Principal: "*"
        Action: execute-api:Invoke
        Resource:
          - execute-api:/*/*/*
        Condition:
          IpAddress:
            aws:SourceIp:
              - "54.64.128.240"
              - "54.178.233.194"
              - "13.112.1.142"
              - "13.112.147.36"
              - "54.238.175.47"
              - "54.168.25.33"
              - "52.192.156.153"
              - "54.178.230.204"
              - "52.197.88.78"
              - "13.112.137.175"
              - "34.211.15.3"
              - "35.160.57.23"
              - "54.68.48.106"
              - "52.88.47.69"
              - "52.68.247.253"
              - "18.182.251.152"
              - "54.248.107.22"
              - "54.248.105.89"
              - "54.238.168.195"
              - "52.192.66.90"
              - "54.65.251.183"
              - "54.250.148.49"
              - "35.166.55.243"
              - "50.112.242.159"
              - "52.199.112.83"
              - "35.73.201.244"
              - "35.72.166.154"
              - "35.73.143.41"
              - "35.74.201.20"
              - "52.198.115.185"
              - "35.165.230.177"
              - "18.236.6.123"

  iamRoleStatements:
    - Effect: "Allow"
      Action:
        - "s3:PutObject"
      Resource: "arn:aws:s3:::${self:custom.kmsSecrets.file.bucket_name}/*"
    - Effect: "Allow"
      Action:
        - KMS:Decrypt
      Resource: ${self:custom.kmsSecrets.file.arn}

functions:
  main:
    handler: handler.main
    events:
      - http:
          path: git/push
          method: post
          async: true
          integration: lambda

    environment:
      REPOSITORY_URL: ${self:custom.kmsSecrets.file.repository_url}
      USER_NAME: ${self:custom.kmsSecrets.file.user_name}
      PASSWORD: ${self:custom.kmsSecrets.file.password}
      BUCKET_NAME: ${self:custom.kmsSecrets.file.bucket_name}
      TARGET_BRANCHES: ${self:custom.kmsSecrets.file.target_branches}

handler.py

import base64
import datetime
import json
import os
import shutil
import subprocess
import traceback
import urllib
import urllib.request

import boto3

REPOSITORY_URL = os.environ.get("REPOSITORY_URL", None)
USER_NAME = os.environ.get("USER_NAME", None).replace("@", "%40")
PASSWORD = os.environ.get("PASSWORD", None)
BUCKET_NAME = os.environ.get("BUCKET_NAME", None)
TARGET_BRANCHES = os.environ.get("TARGET_BRANCHES", [])

class PutSourceIntoS3:
    """
    ソースコードをZIPしてS3に配置する。
    Backlog の Webhook によるトリガーを想定。

    インスタンス変数は、レスポンスで見れるようにしているので、
    複合したパスワードやGITへのリクエストのURLは、設定しないでおく。
    """
    backlog_params = None  # Backlog から受け取ったパラメータ
    branch_name = None  # ブランチ名
    repository_name = None  # リポジトリ名
    request_url = None  # クローン生成のリクエストURL
    root = None  # ルートのパス
    source_path = None  # クローンしたソースコードのパス
    tmpdir = None  # 作業ディレクトリ

    def __init__(self, backlog_params):
        """
        初期値として Backlog のパラメータと root のパスを設定する。
        """
        self.backlog_params = backlog_params
        self.root = os.path.abspath(os.path.join(__file__, ".."))

    def get_decrypted_password(self):
        """
        パスワードを複合する。
        """
        kms = boto3.client('kms')
        return kms.decrypt(
            CiphertextBlob=base64.b64decode(PASSWORD)
        )['Plaintext'].decode('utf-8')

    def get_request_url(self):
        """
        リポジトリのURLから clone 生成のためのユーザー名とパスワード名を追加したリポジトリのURLを生成し設定する。
        """
        parsed_url = urllib.parse.urlparse(REPOSITORY_URL)
        return (
            parsed_url.scheme
            + "://"
            + USER_NAME
            + ":"
            + self.get_decrypted_password()
            + "@"
            + parsed_url.netloc
            + parsed_url.path
        )

    def set_working_dir(self):
        """
        作業ディレクトリ ./tmp/yyyymmddssss/ を生成し設定する。
        """
        now = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
        self.tmpdir = "/tmp/" + now
        os.makedirs(self.tmpdir)

    def set_branch_name(self):
        """
        Backlog のリクエストからブランチ名を取得し設定する。
        """
        self.branch_name = self.backlog_params["body"]["content"]["ref"].split("/")[-1]

    def set_repository_name(self):
        """
        リポジトリのURLからリポジトリ名を取得し設定する。
        """
        parsed_url = urllib.parse.urlparse(REPOSITORY_URL)
        repository_name_with_extension = parsed_url.path.split("/")[-1]
        self.repository_name = repository_name_with_extension.split(".")[0]

    def set_source_path(self):
        """
        zip するファイルのパスを取得する。
        """
        self.source_path = self.tmpdir + "/" + self.repository_name

    def git_clone(self):
        """
        コマンド git clone を実行し tmpdir 配下にファイルを設置する。
        """
        os.chdir(self.tmpdir)
        subprocess.call(
            [
                "git",
                "clone",
                "--branch",
                self.branch_name,
                self.get_request_url(),
            ]
        )

    def zip_files(self):
        """
        source_path に指定されたソースを zip にまとめる。
        """
        shutil.make_archive(self.source_path, "zip", self.source_path)

    def upload_to_s3(self):
        """
        zip したファイルを s3 にアップロードする。
        """
        s3 = boto3.resource("s3")
        bucket = s3.Bucket(BUCKET_NAME)
        try:
            bucket.upload_file(
                f"{self.source_path}.zip",
                f"{self.repository_name}.zip"
            )
        except Exception:
            raise Exception

def main(event, context):
    """
    serverless.yml から呼び出されるメインメソッド。
    """
    try:
        psis = PutSourceIntoS3(event)

        # Ready
        psis.set_working_dir()
        psis.set_branch_name()
        psis.set_repository_name()
        psis.set_source_path()

        if psis.branch_name in json.loads(TARGET_BRANCHES):
            message = f"Put '{psis.branch_name}' of source into S3."
            # Execute
            psis.git_clone()
            psis.zip_files()
            psis.upload_to_s3()
        else:
            message = f"'{psis.branch_name}' of branch is not target."

        return {
            "statusCode": 200,
            "params": vars(psis),
            "message": message,
        }
    except Exception:
        traceback.print_exc()
        return {
            "statusCode": 400,
            "params": vars(psis),
            "error": traceback.format_exc(),
        }
    finally:
        del psis
YouTube