🕸️

古いCircleCI Slack通知からの移行の道2 〜Webhook編

2021/12/14に公開

この記事は、CircleCI Advent Calendar 2021 14日目の記事で、3日目の古いCircleCI Slack通知からの移行の道 の続き的な内容です。
最初に書いた記事は、CircleCI Orbs によってSlack通知を行うというものでしたが、こちらはWebhookを利用しています。Slackアプリの作成部分については、同じなのでこの記事では説明してません。また、意外と悩む人も多いらしい、旧Slack通知の解除方法は前の記事に記載しているため、同じく、こちらでは説明しません。

CircleCIからWebhookを受け取るときのコードも載せておりますので、ぜひお役立てください。

この記事でやることについて、3行まとめ

  • SlackアプリでWebhookURLを取得
  • API Gateway + AWS Lambda のアプリを作る
  • CircleCIプロジェクトでWebhookの設定を行う

CircleCI Slack Orbs の気に食わないところ

前回の記事でも説明しましたが、Slack Orbs は、Jobごとに通知の設定をしなければならないところが面倒なポイントになります。以前、CircleCIにあったSlack通知については、ワークフロー単位での通知でしたし、.circleci/config.yml をSlack通知のためだけに汚してしまったり、フォーマットの統一のためにわざわざ社内用のOrbsを展開する必要性が浮上したり、なんだかいい気持ちではありません。
もちろん、Slack Orbs は、デプロイ先のURLなどの情報をSlackに流したいといったユースケースにおいては、かなり強力なサポーターになるとは思いますが、プロジェクトにあるワークフローの実行結果を通知したい場合のみの場合、少しオーバーではないかと個人的に感じています。

わりと新機能なWebhook

そこで、2021年の9月に、新機能としてリリースされた、ワークフローもしくは、ジョブに関するWebhook機能を活用しましょう。
ドキュメントのユースケースにも

Sending events to communication apps, such as Slack.

などと書いてあり、ドンピシャなことがわかります。この記事では、CircleCIのWebhook機能を利用して、Slackへの通知を行ってみます。

Slack側のWebhook設定

前回の記事で作成したアプリケーションに、Incomming Webhook設定を入れて、URLを取得しましょう。

https://slack.com/intl/ja-jp/help/articles/115005265063-Slack-での-Incoming-Webhook-の利用

アプリ設定の画面左側に、Incomming Webhooks というのがありますので選びます。

この画面で、Activate Incomming Webhooks というのが有効にできますので有効にします。

画面下に、Add New Webhook to Workspaceというボタンがあるので、ここから追加します。

こんなかんじの画面が出てきますので、通知先を選び、許可します。

これで、Webhook URLが入手できました。後で使うのでとっておきます。

CircleCIからのWebhook通知先を作る

CircleCIのWebhookのPayloadは、そのままSlackに送ることはできません。
一旦、CircleCIから、Webhookを受け取り、Slackの通知の形式に変換するアプリケーションが必要です。

また、CircleCIはGitHub Webhookのsecretの仕組みと同じように、signatureが送られてきます。これは、CircleCIが送信したPayloadを事前に設定したキーワードでHMAC計算を行い、受け取った側で検証を行うために付加されているものです。検証を行わない場合、CircleCI以外から来た不正なリクエストを処理してしまったりする危険性がありますので、必ず検証を行いましょう。

サンプルなソースコード

この記事では、AWS Lambda + API Gateway でアプリケーションを展開します。
TypeScript により作られており、Serverlessを利用してデプロイを行えるように作ってあります。

https://github.com/codealcorp/circleci-webhook-to-slack

デプロイ

  • SlackWebhook URL
  • HMACに使うSecret

については、平文で環境変数に入れるわけにもいかないので、SSM Parameter の SecureString として保存するようにします。
パラメータの保存については、この記事が参考になります。今回用意した、serverless.yml では、ssm-parameters.STAGE.REGION.yml からパラメータ名や、arnなどを設定できるようにしています。例えば、以下のように設定できます。

ssm-parameters.dev.ap-northeast-1.yml
# SSMパラメータを参照するためのARNベースを指定
# 基本はRegionと、AWS IDを書き換えるだけでよい。
ssmArnBase: 'arn:aws:ssm:ap-northeast-1:0123456789:parameter'
keys:
  # HMACに使うSecretを格納したSSM Pamareter のキー名を指定
  secret: '/cdl_circleci_wh/secret'
  # SlackWebhookURLを格納したSSM Pamareter のキー名を指定
  slack_webhook_url: '/cdl_circleci_wh/slack_webhook_url'

あとはデプロイするだけです。(AWS CLIで、profileが設定されており、node.js+npmもセットアップされている前提としています。node.jsは、14.xで検証しています。)

npm install
npm run build
npm run deploy

一部解説

シンプルなコードなのであまり解説するところは無いですが、いくつか見ます。

src/handler.ts
const getSignatureFromHeader = (header: APIGatewayProxyEventHeaders) => {
  const signature = header['circleci-signature']
  if (!signature) {
    return null
  }

  const circleCISig = Object.fromEntries(
    signature.split(',').map((s) => {
      const [k, v] = s.split('=')
      return [k, v]
    })
  )

  return circleCISig['v1']
}

HTTPリクエストのヘッダから、signatureを取り出します。v1=xxx,v2=xxxという形式で届き、現状はv1が唯一のバージョンのようです。

src/handler.ts
const getSecret = async () => {
  const result = await ssm
    .getParameter({Name: process.env.SECRET, WithDecryption: true})
    .promise()
  return result.Parameter?.Value
}

// .. 省略

const checkSignature = async (signature: string, body: string) => {
  const secret = await getSecret()

  if (!secret) {
    return false
  }

  const digest = CryptoJS.HmacSHA256(body, secret).toString(CryptoJS.enc.Hex)
  return signature === digest
}

受け取ったsignatureと、アプリケーション側でbodyとsecretを元に作ったHMAC値が一致しているかを確認します。secretについては、SSM Parameter Store から復号化して取り出します。

src/handler.ts
if (!(await checkSignature(signature, event.body))) {
  return {
    statusCode: 400,
    headers: {},
    body: 'Signature mismatch'
  }
}

検証に失敗したら処理を中断してエラーコードを返します。

src/handler.ts
let text = ''
if (body.workflow.status === 'success') {
  text += ':white_check_mark: '
} else {
  text += ':red_circle: '
}
// only for github
text += `${body.workflow.status || ''}: `
text += `Workflow (<${body.workflow.url}|${body.workflow.name}> `
text += `in <${body.pipeline.vcs?.origin_repository_url || ''}|${repoName}> `
text += `(<${body.pipeline.vcs?.origin_repository_url || ''}/tree/${body.pipeline.vcs?.branch || ''}|${body.pipeline.vcs?.branch || ''}>)\n`
text += `${body.pipeline.vcs?.commit?.body || ''} (<${body.pipeline?.vcs?.origin_repository_url || ''}/commit/${body.pipeline.vcs?.revision || ''}|${(body.pipeline.vcs?.revision || '').substring(0, 7)}> by ${body.pipeline.vcs?.commit?.author?.name || ''})`

あまりきれいなコードとは言えないですが、Slack用のPayloadを組み立てます。
形式は、今まで存在していたCircleCI通知に近くなるようにしています。なお、GitHubにしか対応してません!

CircleCIプロジェクトでWebhookの設定をする

デプロイができたら、API Gateway のエンドポイントURLが取得できるので、それをCircleCIのプロジェクトに設定します。

プロジェクトの設定から、Webhookに行き、

  • Webhook name: 管理用。お好きなものを。
  • Receiver URL: デプロイで手に入れた、API Gateway のURL
  • Secret token: SSM Parameter Store に格納した secret と同じ値
  • Certificate verification: WebHook先の証明書を検証するか。普通はON
  • Events: 今回は、Workflow completed にか対応していないので、そちらだけを選びます。

これで出来上がった通知がこれです。ほぼ元の形式と遜色ないレベルで、ワークフローがコケようが、成功しようが1つのみが来るという理想形になりました。

まとめ

ということで、こっちのほうがええやん!ってことで急遽書いた2本目の記事でした。1回用意しておけば、あとはプロジェクトごとにWebhook設定をするだけなので、Orbsに比べると手間は少ないように感じます。
Slack通知については、基本的にこの戦法で行い、ジョブの実行により得た値などをSlackに送りたい場合は、Slack Orbsにより通知を行うという使い分けを行う方針としました。

Discussion