SlackからGitHub Actionsを実行する

こんにちは、メディアサービス開発部サービス分析課の佐藤です。ブックウォーカー社で横断データ基盤を構築しています。
この記事ではSlackからGitHub Actionsを実行するためにSlack appを作った時のことを書いていきます。

外観図

背景

現在サービス分析課が構築しているデータ基盤は複数のサービスのデータを取り込んでおり、多くの社員が利用しています。 この基盤はGitHubでモノレポとして管理されており、サービス分析課が主だって開発しています。

データ基盤はモノレポ構成でサービス分析課が管理

しかし、全社横断基盤を開発しているとあってチーム外のエンジニアからのPRを受け付けることも増えてきました。 基盤利用者に対するセルフサービス化を推し進めるにあたって、いくつか事前に用意しなければならないことがあります。

  • 開発者への権限付与
  • CI/CD
  • デプロイフロー

この記事で説明するGitHub Actionsを実行するコマンドは、BigQueryへのリモート関数1のデプロイをセルフサービス化することを目的として開発しました。 リモート関数の開発は利用するチームごとに行い、デプロイのタイミングも各自に任せようとすると、GCPへのデプロイを関数ごと個別に行わなければなりません。そこでリモート関数単体を開発者が各個人でデプロイできるよう、デプロイフロー整備の一環としてこのコマンドを用意することになりました。

# Slackで実行するコマンドの例
 /dwhbot deploy remote_function:hoge_func

今後は同様の仕組みを利用してデータ加工用のバッチや分析用途のビューなども各サービス開発者がデプロイできるようにしていきたいと考えています。

ではこのコマンドを用意するにあたって行った作業を説明していきます。

Slack appの準備

Slackからコマンドを実行する場合、Slack APIを利用してSlack appを作成する必要があります。 SlackではBoltなどを使った従来の方法と次世代プラットフォームを利用した方法のどちらかを使ってSlack appsを作成することができます。
api.slack.com

まだBETA版扱いとなっていて公式に日本語情報も少ない次世代プラットフォームですが、開発体験も良く今後はこちらが主流になると思われます。今回はたまたま通知用のWebhook目的で作成済みのSlack appがあったことと、Slashコマンドで利用したかったため従来版のAPIを利用しています。次世代プラットフォームにはSlashコマンドはありませんが、代わりにSlack appのアプリアカウントへメンションを飛ばすことで似たようなコマンドを作ることができそうです。

GitHub Appsの準備

外部からGitHub Actionsを実行する際にはGitHub APIを使って repository_dispatch イベントを作成します。 このイベントをワークフローのトリガーイベントに指定することで、外部から任意の処理をGitHub Actionsに行わせることができるようになります。

docs.github.com

GitHub APIから repository_dispatchイベント作成する際には、認証付きのPOSTリクエストを投げる必要があります。 この認証方式は personal access tokenを利用するかGitHub Appを利用するかを選ぶことができます。 今回は業務で利用するため、できるだけ個人のアカウントに紐付かないように組織で運用できるGitHub Appsでの認証方式を選びました。

GitHub Appsを組織の下で作成&インストールし、 repository_dispatchに必要な metadata:readcontents_read&writeの権限を付けます。 docs.github.com

この際、GitHub AppsのIDとインストールIDは後に使うためメモをしておきます。 JWTに用いる秘密鍵も生成し、ダウンロードしておきます。

次にこれらのIDや秘密鍵を用いて実際にGitHub APIを呼び出す処理を実装していきます。

GCP Cloud Functionsにコマンド用の関数を用意する

当記事では旧世代版Slack appを使ってSlashコマンドを実行させるため、Slashコマンドの呼び出し先としてGCPのCloud Functionsを利用します。コマンドの内容に合わせた処理を実際に行うのはこのCloud Functionsにデプロイした関数となります。次世代プラットフォーム版を利用する方はこの節に登場するコードの処理を、denoで実行するFunctions部分のTypeScriptコードに置き換えて読んでください。

repository_dispatchへPOSTするまでの流れは下記のとおりです。

  1. 秘密鍵からJSON Web Token(JWT) を生成する
  2. install access tokenを取得する
  3. repository dispatchにPOSTにする

これらの一連の処理を下記のようにコードとしてCloud Functionsの関数へ用意し、デプロイします。今回はPythonコードでの実装例で説明していきます。

1. 秘密鍵からJSON Web Token(JWT) を生成する

「GitHub Appsの準備」にも書いた通り、GCP Cloud Functionsから GitHub APIのrepository_dispatchへPOSTする際には認証用のトークンが必要となります。このトークンをGitHubから取得するためにダウンロードしておいた秘密鍵を利用します。

Cloud Functionsから秘匿値を利用する場合はSecretManagerを利用し、ダウンロードしたpemファイルをマウントします。また、もしSlack appに次世代プラットフォームを利用する場合は .env を利用することで秘密鍵をコードに含めることなく環境変数に格納できます。

import time
import jwt

def generate_github_jwt():
    with open('/secret/GITHUB_APP_PEM', 'rb') as pem_file:
        signing_key = jwt.jwk_from_pem(pem_file.read())
    payload = {
        'iat': int(time.time()),
        'exp': int(time.time()) + 600,
        'iss': GITHUB_APP_ID, # 事前にメモしたGitHub AppsのID
    }
    jwt_instance = jwt.JWT()
    encoded_jwt = jwt_instance.encode(payload, signing_key, alg='RS256')
    return encoded_jwt

2. install access tokenを取得する

生成したJWTとインストールIDを用いて、アクセストークンを取得します。このアクセストークンはGitHub Appsがインストールされたリポジトリごとに取得できます。インストールの際に事前にmetadata:readcontents_read&writeの権限をつけておく必要があります。

import json
from urllib import request

def get_access_token(jwt_token):
    # 事前にメモしたインストールIDをURLに埋め込む
    install_access_token_url = f'https://api.github.com/app/installations/{INSTALLATION_ID}/access_tokens'
    headers = build_headers(jwt_token)
    data = {
        'repository': REPO_NAME, # リポジトリ名を指定
        'permissions': {
            'contents': 'write',
            'metadata': 'read'
        }
    }
    req = request.Request(install_access_token_url, json.dumps(data).encode(), headers)
    with request.urlopen(req) as res:
        response = json.loads(res.read())

    return response['token']

install access tokenはJSON形式で返ってくるので、読み込んで token から取り出します。

3. repository dispatchにPOSTにする

上記の generate_github_jwtget_access_token を利用して、取得したアクセストークンを使って repository_dispatch へPOSTをしていきます。

    jwt_token = generate_github_jwt()
    access_token = get_access_token(jwt_token)
    repository_dispache_url = f'https://api.github.com/repos/{ORG_NAME}/{REPO_NAME}/dispatches'
    headers = build_headers(access_token)
    data = {
        'event_type': 'remote-function-deploy', # workflowのトリガーイベント判定に利用可能
        'client_payload': {
            'function_name': deploy_target, # デプロイしたい関数名
            'source_directory': deploy_target,
        }
    }
    req = request.Request(repository_dispache_url, json.dumps(data).encode(), headers)
    with request.urlopen(req) as res:
        body = res.read().decode('utf-8')

POSTに含まれる event_typeclient_payloadは任意の文字列とJSONを指定できます。これらはGitHub Actionsのワークフロー内部で取り扱うことができます。

上記のコードを含んだ関数をCloud Functionsへデプロイし、デプロイ後に得られたエンドポイントURLをSlackの Slash commands から Request URL に設定しておきます。 また、SlashコマンドからエンドポイントへはPOSTリクエストが送られますが、ペイロードには様々な情報が含まれています。コマンドの内容を詳しく指定したい場合は text からコマンドに続く入力内容を取り出してパースすると良いでしょう。

api.slack.com

Slashコマンドは3秒以内にHTTP 200レスポンスを返す必要があります。ただし、バックエンドとしてCloud Functionsを利用する場合どんなに早く返す処理内容にしていても初回起動時には間に合いません。対応策としてインスタンスの最小数を設定して常時起動させっぱなしにする方法があります。このコマンド用の関数では大した処理はしないので、できる限りリソースを下げた状態で1インスタンスを常時稼働させています。

SlackとCloud Functionsを繋げることができたら、最後にGitHub Actions側のワークフローを用意します。

GitHub Actionsにrepository dispatchのworkflowを作る

Appさえ用意されていればGitHub Actrions側では特に気をつけることはありません。ワークフローのyamlの先頭で発火元のイベント指定を repository_dispatch と指定するだけです。またrepository_dispatchイベントは外部からの入力としてtypesに文字列を指定できるので、実行させたいコマンドごとに types指定を分けて作ることができます。

on:
  repository_dispatch:
    types: [remote-function-deploy] # コマンド例) `/dwhbot deploy remote_function`

この types を使ってGitHub Actionsの中に様々なワークフローを用意しておけば、コマンドの引数で使い分けることができるようになるでしょう。

また、Cloud Functions側で返すSlashコマンド向けのレスポンスとは別に、GitHub Actionsの実行結果も別途ワークフローの中からSlackに通知するようにしておくとコマンドの実行状況が分かりやすくなるでしょう。

終わりに

次世代プラットフォームではなく、旧世代版のSlack appでの説明となってしまいましたが、どちらで作成してもSlackからGitHub Actionsを実行する際の大まかな流れは同様かと思います。 この記事が自分と同じようにSlackからGitHub Actionsを実行したいと考えている人のお役に立てれば幸いです。

今回Slackでコマンドを作るきっかけとなったBigQueryでのリモート関数事例についてはまた次回、記事を執筆したいと思います。


  1. BigQueryのリモート関数を活用する事例の詳細についてはまた次回書きたいと思います。