Rundeck のジョブ実行結果を Hangouts Chat に通知してみた

#はじめに
G Suite向けに『[Google Hangouts Chat](https://gsuite.google.com/products/chat/)』が正式リリースされましたので、Rundeckのジョブ実行結果の通知先として実装してみました。

#実装イメージ
Rundeckには実行結果の通知方法として、デフォルトで Webhook も用意されています。ただ、この Webhook を利用した場合、POSTリクエストボディがXML形式で送信されますので、Hangouts ChatがWebhook受信できるよう、JSON形式のメッセージフォーマットに変換して渡す必要があります。
そこで「Amazon API Gateway」＋「AWS Lambda」を利用し、下図のようなトポロジを構成して実装しました。
![Hungouts_Chat_Topology.png](https://qiita-image-store.s3.amazonaws.com/0/94476/afbd2afa-01f4-2dc5-7df1-b0684cead8ee.png)

##Rundeck Webhookリクエストデータ
RundeckのWebhookリクエストボディは、次のような構造のXMLフォーマットとなります。

```xml:例
<notification trigger='success' status='succeeded' executionId='1154'>\n
  <executions count='1'>
    <execution id='1234' href='http://exzample.com/project/MISC/execution/follow/1154' permalink='' status='succeeded' project='MISC'>
      <user>hoge</user>
      <date-started unixtime='1520401717000'>2018-03-07T05:48:37Z</date-started>
      <date-ended unixtime='1520401730000'>2018-03-07T05:48:50Z</date-ended>
      <job id='XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX' averageDuration='16294' href='XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX' permalink='http://example.com/project/MISC/job/show/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX'>
        <name>TestJob</name>
        <group></group>
        <project>MISC</project>
        <description>テストジョブ</description>
        <options>
        </options>
      </job>
      <description>ls -l ~/</description>
      <argstring></argstring>
      <successfulNodes>
        <node name='examle.com' />
      </successfulNodes>
    </execution>
  </executions>
</notification>
```

#チャットルームとBOTの準備
##チャットルームの作成
[Hangouts Chat サービスページ](https://chat.google.com)にアクセスします。

左メニュー上部にある`ユーザー、チャットルーム、botを検索`をクリックし、`チャットルームを作成`をクリックします。
![2018-03-09_10h37_54.png](https://qiita-image-store.s3.amazonaws.com/0/94476/b27f179c-0733-bf4a-7fb4-5d67d3b16473.png)

`チャットルーム名`に任意のルーム名を入力し、`作成`ボタンをクリックします。
今回は`Rundeck notification`というチャットルーム名にしました。
![screencapture-chat-google-add-room-2018-03-09-10_40_40.png](https://qiita-image-store.s3.amazonaws.com/0/94476/57ca3095-ecc6-f48a-03af-eefc8b56615b.png)

##BOT(Webhook)の作成

作成したチャットルームを開き、上部のルーム名をクリックし、`Webhook を設定`をクリックします。
![screencapture-chat-google-room-AAAACA73PAA-2018-03-09-09_35_18.png](https://qiita-image-store.s3.amazonaws.com/0/94476/10f1948d-76c7-90a6-4e32-04a3a1970d1d.png)

`WEBHOOK を追加`をクリックします。
![screencapture-chat-google-room-AAAACA73PAA-2018-03-09-09_35_55.png](https://qiita-image-store.s3.amazonaws.com/0/94476/1b2011e0-fdda-0dd1-ad22-93c1ba94e36a.png)

`名前`に任意の名称（これがBOTの名前となります。今回は`Rundeck`としました。）と、`アバターのURL`にBOTのアイコンとなるイメージ画像のURL（今回はRundeck公式サイトにあるアイコンのURL）を入力して`保存`をクリックします。
![screencapture-chat-google-room-AAAACA73PAA-2018-03-09-09_36_26.png](https://qiita-image-store.s3.amazonaws.com/0/94476/87c7a2a7-c15e-257f-98eb-b0d8e32c3943.png)

以上でWebhookが作成されます。また`Webhook の URL`に記載されているURLがWebhookのエンドポイントとなり、このURLにメッセージリクエストをPOSTすることで、チャットルームに通知することができます。
![screencapture-chat-google-room-AAAACA73PAA-2018-03-09-09_36_57.png](https://qiita-image-store.s3.amazonaws.com/0/94476/c8aaedb2-81b2-c7f3-3869-fef3b9eaf6c2.png)


#Amazon API Gateway
##AWS Lambdaの作成（最低限の設定のみ）
API GatewayにLambdaをバックエンドとして設定する際、指定されたLambda関数の存在をチェックされますので、下図の通り最低限の情報でLambda関数を作成します。今回は関数名を`fnc_webhook_to_hangouts`とし、ランタイムに`Python 3.6`を指定して作成しました。
![screencapture-ap-northeast-1-console-aws-amazon-lambda-home-2018-03-09-09_17_45.png](https://qiita-image-store.s3.amazonaws.com/0/94476/30c1b466-5305-c7e5-637d-df55807e42cb.png)

##API Gateway 設定

新しいAPIを作成します。今回は`api_rundeck_webhook`というAPI名で作成しました。
![screencapture-ap-northeast-1-console-aws-amazon-apigateway-home-2018-03-09-09_10_39.png](https://qiita-image-store.s3.amazonaws.com/0/94476/c3d3ec5c-645d-3bd0-394c-9d49fbb0eac5.png)

リソース `/api/v1`を作成し、そのリソースにPOSTメソッドを作成します。更に統合ポイントのタイプにLambda関数を選択し、前述で作成したLambda関数のリージョンと関数名を指定します。
![screencapture-ap-northeast-1-console-aws-amazon-apigateway-home-2018-03-09-09_14_38.png](https://qiita-image-store.s3.amazonaws.com/0/94476/84d90759-046e-86eb-20ba-089ec00a404f.png)

`メソッドの実行`ページにある`統合リクエスト`を開きます。
更に`統合リクエスト`ページの最下部にある`本文マッピングテンプレート`を展開し、`Content-Type`に`text/xml`を作成（RundeckのWebhookリクエストヘッダーの Content-type が`text/xml`な為）、マッピングテンプレートは次の内容を入力します。

```json
{
    "body" : $input.json('$')
}
```

このマッピングテンプレートにより、RundeckのWebhookリクエストボディのXMLデータが、JSON要素の`body`にくるまれてLambda関数に渡されます。

![screencapture-ap-northeast-1-console-aws-amazon-apigateway-home-2018-03-09-09_21_13.png](https://qiita-image-store.s3.amazonaws.com/0/94476/910e590c-c5e4-e117-12c2-8c21a215eb58.png)

これでAPI Gatewayの設定が終わりましたので、リソースをステージにデプロイします。
今回は`prod`というステージにデプロイしました。
またステージ`prod`の POST メソッドの`URLの呼び出し`にあるURIが、Rundeckから見た Webhook 送信先となります。
![screencapture-ap-northeast-1-console-aws-amazon-apigateway-home-2018-03-09-09_22_43.png](https://qiita-image-store.s3.amazonaws.com/0/94476/69a64479-7336-dd35-23ef-ee3e52545bb0.png)


#AWS Lambda
##開発環境の準備
ローカルマシンに Python3.6 の開発環境を用意します。（開発環境の構築手順は割愛します）
更にLambda関数で利用するモジュールを、lambda_function.pyを作成するカレントディレクトリ直下にインストールします。

```text:requirements.txt
httplib2
xmltodict
pytz
```

```bash
pip install -r requirements.txt -t .
```

##AWS Lambda関数の作成
Lambda関数本体となるファイル`lambda_function.py`を作成します。
今回は次の内容で書きました。

```python:lambda_function.py
# -*- coding: utf-8 -*-

import os
from json import dumps, loads
from httplib2 import Http
import xmltodict
from datetime import datetime
import pytz

def dtConv(iso_str):
    """
    日付変換
    ISO8601(YYYY-mm-ddTHH:MM:SSZ) -> YYYY/mm/dd HH:MM:SS (JST)
    """
    dt = datetime.strptime(iso_str, '%Y-%m-%dT%H:%M:%SZ')
    dt = pytz.utc.localize(dt).astimezone(pytz.timezone("Asia/Tokyo"))
    return dt.strftime('%Y/%m/%d %H:%M:%S')

def getMessage(xml):
    """
    Hungouts Chat 用メッセージ生成
    XMLメッセージから、ハングアウトチャットのカードメッセージを生成
    """
    dict = xmltodict.parse(xml)
    keyLabel = {
        "status": "Status",
        "execid": "Execution ID",
        "project": "Project",
        "start": "Execution period",
        "name": "Job Name",
        "desc": "Job Description",
    }
    data = {}
    data['project'] = dict['notification']['executions']['execution']['@project']
    data['execid']  = dict['notification']['executions']['execution']['@id']
    data['name']    = dict['notification']['executions']['execution']['job']['name']
    data['desc']    = dict['notification']['executions']['execution']['job']['description']
    data['status']  = dict['notification']['@status']
    data['start']   = dtConv(dict['notification']['executions']['execution']['date-started']['#text'])
    data['finish']  = dtConv(dict['notification']['executions']['execution']['date-ended']['#text'])
    data['url']     = dict['notification']['executions']['execution']['@href']
    data['group']   = dict['notification']['executions']['execution']['job']['group']

    message = {'cards': [{'header':{},'sections': [{'widgets': []}]}]}
    message["cards"][0]['header']['title'] = "Job '" + data['name'] + "' " + data['status']
    

    for key, val in data.items():
        if key in {"url", "group", "finish"}:
            continue
        KeyVal = {"keyValue":{}}
        KeyVal["keyValue"]["topLabel"] = keyLabel[key]
        if key == "execid":
            KeyVal["keyValue"]["content"] = "#" + val
            KeyVal["keyValue"]["onClick"] = {}
            KeyVal["keyValue"]["onClick"]["openLink"] = {}
            KeyVal["keyValue"]["onClick"]["openLink"]["url"] = data["url"]            
        elif key == "name":
            group = data["group"] if data["group"] is not None else '' 
            KeyVal["keyValue"]["content"] = "%s/ %s" % (group, val)            
        elif key == "start":
            KeyVal["keyValue"]["content"] = val + " - " + data['finish']
        else:
            KeyVal["keyValue"]["content"] = val
        message["cards"][0]["sections"][0]["widgets"].append(KeyVal)

    return(message)

def lambda_handler(event, context):
    API_URI = os.environ['API_URI']
    xml = event['body']
    message = getMessage(xml)
    #print(dumps(message, indent=4))

    try:
        message_headers = { 'Content-Type': 'application/json; charset=UTF-8'}

        http_obj = Http()

        response = http_obj.request(
            uri=API_URI,
            method='POST',
            headers=message_headers,
            body=dumps(message),
        )

        print(response)
        return(True)

    except Exception as e:
        raise(e)
```

この関数により、`event`で受け取ったJSON要素`body`にくるまれたXMLデータを取り出し、Hangouts Chat カードメッセージのフォーマットに変換しています。


##AWS Lambda関数、関連モジュールのデプロイ
`lambda_function.py`、とカレントディレクトリにインストールした各モジュールを、ZIPファイルにアーカイブします。

```bash
zip -r upload.zip lambda_function.py httplib2 xmltodict.py pytz
```

アーカイブしたZIPファイルを、前述の「最低限のみ」設定したLambda関数`fnc_webhook_to_hangouts`にアップロードします。
![screencapture-ap-northeast-1-console-aws-amazon-lambda-home-2018-03-09-12_24_37.png](https://qiita-image-store.s3.amazonaws.com/0/94476/6b889b30-8988-5fd8-2553-c08251abfa9f.png)


##AWS Lambda 環境変数の設定
Lambda関数に環境変数`API_URI`を設定します。変数の値には Hangouts Chat で作成した Webhook `Rundeck`の`Webhook の URL`を入力します。
![screencapture-ap-northeast-1-console-aws-amazon-lambda-home-2018-03-09-09_31_52.png](https://qiita-image-store.s3.amazonaws.com/0/94476/c278ff65-95ff-ff47-6211-4651c39049d1.png)

#Rundeck ジョブの Webhook 設定
ここまでの手順で Hangouts Chat へメッセージを通知する準備が整いましたので、Rundeckのコンソールにアクセスし、Webhook通知を送りたいジョブにAPI GatewayのURLを設定します。
![screencapture-aw01rd10-jim_domain-local-project-MISC-job-edit-1ab5e2d4-7822-492c-8537-632f4ff6a05e-2018-03-09-10_07_04.png](https://qiita-image-store.s3.amazonaws.com/0/94476/13790ee8-99b0-3fb9-643c-bed29e1de8be.png)

#実行結果通知の確認

Hangouts Chat のチャットルームに、ジョブ実行結果が次のようなBOTメッセージとして通知されます。
（ジョブ実行成功時）
![screencapture-chat-google-room-AAAACA73PAA-2018-03-09-09_37_53 - コピー - コピー.png](https://qiita-image-store.s3.amazonaws.com/0/94476/b6344fb4-3238-dd84-1e65-1289c9c06f6d.png)

（ジョブ実行失敗時）
![screencapture-chat-google-room-AAAACA73PAA-2018-03-09-09_37_53 - コピー.png](https://qiita-image-store.s3.amazonaws.com/0/94476/e56b7e77-7c17-f0fe-ebfa-482dd786fb28.png)

#参考ページ
https://developers.google.com/hangouts/chat/
http://rundeck.org/docs/manual/jobs.html#webhooks