アクトインディ開発者ブログ

子供とお出かけ情報「いこーよ」を運営する、アクトインディ株式会社の開発者ブログです

Github serviceをwebhookに変更した

morishitaです。

Github Service が使えなくなったので対応しました。

次の記事には2019年1月末に止めるよって書いてあるので、今月中になんとかすればいいかなと思っていました。
ところが、2019/01/08 から突然動かなくなったのです。

developer.github.com

最初は月末に廃止する作業でなにかミスがあったのだろう、じきに回復するだろうと思っていました。
しかしGitHub Statusを確認すると、「1/7 19:00 UTCから止め始めるよ」って書いて あるではないですか!

廃止のスケジュールが前倒されたの?こちらの勘違い?と思ったものの、どうせ今月中にはやらない行けない作業なのでこちらも予定を変更して対応しました。

なにに利用していたのか?

Github Serviceを何に使っていたのかというと、 以前、このブログでも紹介した いこレポの自動テストで利用していたのです。

tech.actindi.net

詳しくは上記の記事を見てほしいのですが、ざっくりなにをやっているかというと次の通りです。

  1. Github の PullRequest のブランチにPushする
  2. PushがAmazon SNSに通知される
  3. Amazon SNSがLambda関数をトリガーして、CodebuildによりRSpecを実行する
  4. RSpecの結果をGithubのPullRequestとSlackに通知する

このなかで、PushをAmazon SNSに通知されるという処理に利用していたのです。

移行方法の検討

移行方法を検討するにあたって、もともとの仕組みを少し調べました。

API的には、GithubServiceの設定内容も Webhook APIで取得できます。 なので、ちょっと設定をいじればできちゃうんじゃないかと思いましたが、どうやらその方法はなさそうでした。

で、Webhookに移行する方法として次の3案を考えました。

現行の処理の流れは次のとおりです。

Github Services => AWS SNS => Lambda => CodeBuild => 以下略。

これを次の3案のいずれかに移行しようと考えました。

  1. Github Webhook => AWS SNS => Lambda => CodeBuild
  2. Github Webhook => API Gateway => Lambda => AWS SNS => Lambda => CodeBuild
  3. Github Webhook => API Gateway => Lambda => CodeBuild

案1はAWS SNSにHTTPのエンドポイントがあれば、と思ったのですがどうやらトピックに直接パブリッシュできる エンドポイントはなさそうです。なので、却下。

案2はAWS SNSにパブリッシュする必要なHTTPエンドポイントをAPI Gateway+Lambdaで作ってしまうというもの。
SNS以降の処理に変更が不要なところが利点。

案3はAWS SNSの利用をやめ、CodeBuildをトリガーしているLambdaを変更して直接Webhookのリクエストを受けるようにするもの。
現行の仕組みを変更するけれど、仕組みは簡素になるという案。

いずれにせよLambdaでWebhookからのリクエストを受ける必要があるので、その部分をプロトタイピングしてどんなリクエストが来るのか調査しました。

調査した結果、AWS SNS経由で受け取っていたGithubのPullRequest情報と内容と形式が同じ1だったので、変更も軽微で済むと判断して、案3を採用しました2

Webhookへの移行

Webhookの設定

Webhookは次のように設定します。

項目 設定値
Payload URL API GateWayのURL
Content-type application/json
Secret 推測されにくい文字列を設定
SSL verification Enable SSL verification
Which events would you like to trigger this webhook? Let me select individual events. > PullRequest

説明が前後しますが、Payload URL には後述の Serverless Framework の設定を追加すればAPI Gatewayが登録されるので、そのエンドポイントURLを設定します。

プルリクに対する操作で実行したいので、送信するイベントはPullRequestだけで必要十分です。

Webhookからのリクエストの受けの変更

Webhookからのリクエストのヘッダやボディは Lambda関数にはハンドラーの引数 event オブジェクトに格納されて渡ってきます。 次のような感じです。

{
    "event": {
        "resource": "/test",
        "path": "/test",
        "httpMethod": "POST",
        "headers": {
            〜略〜
            "X-Hub-Signature": "sha1=<bodysecretでハッシュ化した値(後述)>" // 利用する
        },
        "multiValueHeaders": {
            〜略〜
        },
        "queryStringParameters": null,
        "multiValueQueryStringParameters": null,
        "pathParameters": null,
        "stageVariables": null,
        "requestContext": { 〜略〜 },
        "body": "〜後述〜",
        "isBase64Encoded": false
    }
}

で、リクエストボディは次の様な感じ。 実際にはJSON.stringify された文字列が入っているのでJSON.parseして読み込みます。

{
    "body": {
        "action": "labeled", // 利用する
        "number": <PullRequestの番号>, // 利用する
        "pull_request": { 
            "url": "https://api.github.com/repos/〜略〜",
            "id": 242573289,
            "node_id": "〜略〜",
            "html_url": "https://github.com/〜略〜", // 利用する
            "diff_url": "https://github.com/〜略〜",
            "patch_url": "https://github.com/〜略〜",
            "issue_url": "https://api.github.com/repos/〜略〜",
            "number": 883,
            "state": "open",
            "locked": false,
            "title": "<PullRequestのタイトル(略)>",     // 利用する
            "user": { <PullRequestのユーザ情報(略)> },
            "body": "<PullRequestの本文(略)>",
            "created_at": "2019-01-07T07:21:06Z",
            "updated_at": "2019-01-09T03:28:00Z",
            "closed_at": null,
            "merged_at": null,
            "merge_commit_sha": "〜略〜",
            "assignee": null,
            "assignees": [],
            "requested_reviewers": [],
            "requested_teams": [],
            "labels": [
                {
                    "id": 936758322,
                    "node_id": "〜略〜",
                    "url": "https://api.github.com/repos/〜略〜",
                    "name": "自動テスト対象", // 利用する
                    "color": "fbca04",
                    "default": false
                }
            ],
            "milestone": null,
            "commits_url": "https://api.github.com/repos/〜略〜",
            "review_comments_url": "https://api.github.com/〜略〜",
            "review_comment_url": "https://api.github.com/repos/〜略〜",
            "comments_url": "https://api.github.com/repos/〜略〜",
            "statuses_url": "https://api.github.com/repos/〜略〜",
            "head": {
                "label": "〜略〜",
                "ref": "<ブランチ名>", // 利用する
                "sha": "<コミットID>", // 利用する
                "user": { 〜略〜 },
                "repo": { 〜略〜 }
            },
            "base": { 〜略〜 },
            "_links": { 〜略〜 },
            "author_association": "CONTRIBUTOR",
            "merged": false,
            "mergeable": true,
            "rebaseable": true,
            "mergeable_state": "clean",
            "merged_by": null,
            "comments": 0,
            "review_comments": 0,
            "maintainer_can_modify": false,
            "commits": 5,
            "additions": 50,
            "deletions": 49,
            "changed_files": 1
        },
        "label": {
            "id": 936758322,
            "node_id": "〜略〜=",
            "url": "https://api.github.com/repos/〜略〜",
            "name": "自動テスト対象",
            "color": "fbca04",
            "default": false
        },
        "repository": { 〜略〜 },
        "organization": { 〜略〜 },
        "sender": { 〜略〜 }
    }
}

// 利用するとコメントした項目を処理の中で利用しています。

幸い、もともとの実装が、AWS SNSから渡されるオブジェクトからPullRequestの情報を取り出す部分を関数化していたので、event.bodyから取り出す様に変更するだけでほぼ対応できました。

API Gatewayの追加

ServerlessFrameworkを利用しているので次のようなイベント設定を追加するだけです。
簡単!

events:
    - http:
        path: pull_request
        method: post

デプロイするとエンドポイントが決まるので、そのURLをGithubのWebhookの設定の Payload URL に設定します。

不正リクエストの判別

これまで、どうしてGithub Serviceを使っていたのかというと、手軽だったということに尽きます。

いくらRSpecを実行するだけとはいえノーチェックのAPIエンドポイントをパブリックなインターネットに晒すのは避けたいなぁと思うと、簡易的にでも認証処理をしたり不正なリクエストを判別できないといけません。
Github Serviceだと、設定するだけでIAMによる認証付きでSNSのトピックにパブリッシュできたのです。

しかし、WebhookではIAMによる認証はできません。
そこで、WebhookのSecretを利用して、不正なリクエストを判別しています。

Github Webhookの設定画面で任意項目としてSecretという項目があります。

f:id:HeRo:20190115082518p:plain
Github Webhook

この Secret に適当な文字列を入力しておくとその文字列をキーとしてリクエストボディのHMAC値を計算してX-Hub-Signatureヘッダの値に入れてリクエストしてくれます。
リクエストを受けた際に、Lambda内部で再度リクエストボディのHMACを計算してX-Hub-Signatureヘッダの値と比較します。 するとリクエストの改ざんを検知できますし、Secretの値を知っているということで送信元の認証にもなります。

具体的には次のようなコードでチェックします。

/**
 * @param {String} secret Secretの値
 * @param {Object} body Webhookからのリクエストボディ
 */ 
function signRequestBody(secret, body) {
  return `sha1=${crypto.createHmac('sha1', secret).update(body, 'utf-8').digest('hex')}`;
}

/**
 * Github webhoookのリクエストが正しいことをチェックする。
 * X-Hub-Signature と リクエストホディのハッシュが一致することを確認する
 * @param {Event} event Lambdaのイベントオブジェクト
 * @return 一致するとき true。それ以外はfalse
 */
function isValidRequest(event) {
  const signature = event && event.headers && event.headers['X-Hub-Signature'];
  const secret = process.env.GITHUB_WEBHOOK_SECRET;
  const signedBody = signRequestBody(secret, event.body)
  return signature === signedBody
}

上記の isValidRequest関数を最初に実行し、true が帰ってきたときのみ処理するようにしました。

ということで、プルリクにpushすればテストが回る状況が回復しました3

参考

最後に

アクトインディではエンジニアを募集しています。


  1. SNS経由だとevent.Records[0].Sns.Messageに格納されていたものが、event.bodyに格納されていました。

  2. スッキリするのは案3だけど、予定前倒しで時間取れないし最初は既存部分に手を入れなくていい案2かなーと考えていました。

  3. 実際にはプルリクへのすべてのプッシュに対してテストを実行しても無駄な場合もあるので、「自動テスト対象」というラベルをつけている場合だけに実行するようにしています。