WebEx TeamsにPlaybookの結果を通知するAnsible Callback Plugin

# はじめに

この記事はシスコの有志による Cisco Systems Japan Advent Calendar 2019 の 2 日目として投稿しています。

2017年版: https://qiita.com/advent-calendar/2017/cisco
2018年版: https://qiita.com/advent-calendar/2018/cisco
2019年版: https://qiita.com/advent-calendar/2019/cisco

書くこともぱっと思いつかなかったので、去年に引き続き、Ansibleネタで。
結構長いAnsible Playbookを実行すると、終わるのを待ってられないので通知したくなるわけですが、普通だと、Playbookの最後に通知用のモジュールを使って送っていると思います。

しかしこの方法だと、Playbookを作る度にそれを実装したりしなければならなかったり、実行に失敗したときにも通知をできるようにしなきゃいけなかったりして結構面倒です。

まあ、Roleとか作って頑張ってもいいんですが、Callback Pluginを使って結構便利に実装できるのでCallback Pluginを使った方法を紹介したいと思います。

Callback Pluginの作成手順ですが公式になんとなくレベルで書いてあったかもしれないんですが、あまりちゃんとした記事もみあたらなかったので、Callback Pluginの作成手順などもあわせて説明してみました。

# 目標
以下のような機能をCallback Pluginで実装することが目標です。

- Playbookの終了時に結果を通知できる
- 全体のメッセージを変数で設定できる
- ホストごとに、失敗したときと成功したときに設定できる

# 実行例
こんな感じになりました

サンプルのPlaybook

```yaml:playbook_sample.yaml
---
- name: Sample Playbook to send WebEx message when Playbook complete
  gather_facts: no
  hosts: all
  connection: local
  vars:
    notify_webex_destination: foobar@example.com
    notify_webex_when_finished: The playbook completed
    notify_webex_when_success: "{{ inventory_hostname }} succeeded"
    notify_webex_when_failed: "{{ inventory_hostname }} failed"
  tasks:
    - name: Makes some hosts fail
      fail:
        msg: "{{ groups }}"
      when: inventory_hostname in groups.failure
```

以下の変数を設定することで動作を変更できます。

- `notify_webex_destination` ... 通知の宛先
- `notify_webex_when_finished` ... Playbookが終了した際に表示させるメッセージ
- `notify_webex_when_success` ... ホストが成功したときに表示させるメッセージ
- `notify_webex_when_failed` ... ホストが失敗したときに表示させるメッセージ

# Callback Pluginの実装

## Callback Pluginの作成手順

Callback Pluginの作成手順ですが、以下のような感じでPythonで書けます。
PythonファイルをAnsibleがPluginをロードするパスに置けばOKです(Playbook/Roleなら、callback_pluginsディレクトリ配下, 全体ならansible.cfgで設定したパス)。

親にCallbackBaseで、CallbackModuleというクラスを定義して、その中にトリガとなるイベントに対応するコールバック関数(`v2_playbook_on_start`)を定義します。

```python:notify_webex.py
# Python2/3のコンパチやライブラリの衝突を防ぐために入れておく
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type

from ansible.plugins.callback import CallbackBase


class CallbackModule(CallbackBase):
    # Version。特に理由が無ければ 2.0
    CALLBACK_VERSION = 2.0
    # コンソール出力制御なら`stdout`。他は何でも良さげだが、notfication, agreegationが使われている
    CALLBACK_TYPE = 'notification'
    # Callback Pluginの名前
    CALLBACK_NAME = 'notify'
    # WhiteListで制御するかどうか
    CALLBACK_NEEDS_WHITELIST = True
    def __init__(self, *args, **kwargs):
        super(CallbackModule, self).__init__(*args, **kwargs)
        self.logged = []

    # 以下はトリガごとに実行する処理
    def v2_playbook_on_start(self, playbook):
        self.logged.append("Playbook {} started".format(playbook._file_name))

    def v2_playbook_on_task_start(self, task, is_conditional):
        self.logged.append("Task {}",format(self.task.name))

    def v2_playbook_on_stats(self, stats):
        self.loged.append(stats)
        with open('/tmp/log.txt', 'aw') as f:
            f.write(self.logged)

```

注意点としては、公式ドキュメントにコールバック関数がどのタイミングで実行されるのかがちゃんと書かれていないので、わかりにくいものに関してはトライアル&エラーが必要そうです。あと、v1, v2があるんですが今はv2が使われているみたいです。

具体的にどのようなコールバック関数を使えるのかはドキュメントを見てもなさそうだったので、Ansibleのコードをざっと眺めて使えそうなコールバック関数をまとめてみました。

注意: 2.7のコードを見てまとめたので変更があるかもしれません

- v2_on_any ... 実装されている全てのタイミング
- v2_runner_on_failed ... Taskが失敗したとき
- v2_runner_on_ok ... TaskがOKのとき
- v2_runner_on_skipped ... Taskがスキップされたとき
- v2_runner_on_unreachable ... TaskでホストがUnreachableのとき
- v2_runner_on_async_poll ... (未実装) Taskでasync pollを実行したとき
- v2_runner_on_async_ok ... (未実装) TaskでAsync PollがOKのとき
- v2_runner_on_async_failed ... (未実装) TaskでAsync Pollに失敗したとき
- v2_playbook_on_start ... Playbook実行時
- v2_playbook_on_notify ... PlayでNotify実行時
- v2_playbook_on_no_hosts_matched ... Playbookのホストがマッチしないとき
- v2_playbook_on_no_hosts_remaining ... max_fail_percentageを超えた場合に実行
- v2_playbook_on_task_start ... PlayでTaskを開始するとき
- v2_playbook_on_cleanup_task_start ... (未実装) PlaybookでCleanup Taskを実行するとき
- v2_playbook_on_handler_task_start ... (未実装) PlaybookでHanderのTaskを実行するとき
- v2_playbook_on_vars_prompt ... Playでvars_promptを実行するとき
- v2_playbook_on_import_for_host ... (未実装)
- v2_playbook_on_not_import_for_host ... (未実装)
- v2_playbook_on_play_start ... Play開始時
- v2_playbook_on_stats ... Playbookが終了したとき
- v2_on_file_diff ... diffを結果として返すモジュールで実際に差分がある場合
- v2_playbook_on_include ... include_の処理が発生したとき
- v2_runner_item_on_ok ... Taskのループでの処理ごとにitemがOKのとき
- v2_runner_item_on_failed ... Taskのループでの処理ごとにitemがfailedのとき
- v2_runner_item_on_skipped ... Taskのループでの処理ごとにitemがskippedのとき
- v2_runner_retry ... Task実行時
- v2_runner_on_start ... Taskが開始されるとき(2.8から)

## コールバック関数で変数を使う
コールバック関数によって渡される変数が異なります。例えば`v2_playbook_on_start`ではPlaybookのオブジェクトが渡されますが、`v2_playbook_on_stats`では、AggregateStatsオブジェクト(結果の情報が入る)が渡されます。

```python:notify_webex.py
    # Playbookオブジェクトが渡される
    def v2_playbook_on_start(self, playbook):
        self.logged.append("Playbook {} started".format(playbook._file_name))

    # AggregateStatsオブジェクトが渡される
    def v2_playbook_on_stats(self, stats):
        self.loged.append(stats)
        with open('/tmp/log.txt', 'aw') as f:
            f.write(self.logged)
```

このため、渡してくれない情報を利用するにはひと工夫が必要です。

今回の例ではPlaybookで定義している変数を使いたかったので、まず`v2_playbook_on_start`でPlaybookのオブジェクトをselfに格納して、それを`v2_playbook_on_stats`で使っています。

更にPlaybook.get_plays()で、Playオブジェクトをとってきています。Playbookには複数のPlayを設定できるので、厳密には複数あった場合の処理も考えるべきなんでしょうが、ここでは面倒なので最後のPlayだけを対象にしています。

```python:notify_webex.py
    def v2_playbook_on_start(self, playbook):
        self.playbook = playbook

    def v2_playbook_on_stats(self, stats):
        playbook = self.playbook
        play = playbook.get_plays()[-1]
```

ここからが更に面倒なんですが、Ansibleでは変数にJinja2テンプレートが使われている場合、そのまま変数を取ってこようとするとテンプレート化された値をコンバートしてくれません。

Ansible内部ではTemplarオブジェクトを使って変数内のテンプレートをコンバートすることができますが、Templarに利用する変数を渡す必要があります。これは別途VariableManagerで管理されているようです。

VariableManager.get_vars()で変数は取得可能ですが、この際に、対象となるPlayやHostを設定する必要があります。Taskも設定できますが今回はTaskの変数は使わないと思うので省略します。

あまり細々としたことを考えるのが面倒になってきたので、`notify_webex_destination`, `notify_webex_when_finished`に関してはPlayで設定されている変数までを範囲にして取得し、`notify_webex_when_success`と`notify_webex_when_failed`に関してはPlay+Hostに設定されている変数を対象としています。サンプルでは`inventory_hostname`をテンプレート変数として使っていますが、これはHostも設定しておかないとコンバートできないので、`notify_webex_when_finished`とかで使うとエラーになってしまいます。

```python:notify_webex.py
        variable_manager = play.get_variable_manager()
        inventory_hosts = variable_manager._inventory.get_hosts()
        play_vars = variable_manager.get_vars(play=play)

        templar = Templar(playbook._loader, variables=play_vars)
        destination = play_vars.get('notify_webex_destination')

        if destination is None:
            self._display.warning(
                "notify_webex_destination was not provided. Disalbed webexteam Callback plugin")
            return
        self.destination = templar.template(destination)

        messages = list()

        hosts = sorted(stats.processed.keys())
        for hostname in hosts:
            s = stats.summarize(hostname)
            host = [h for h in inventory_hosts if h.name == hostname][0]
            host_vars = variable_manager.get_vars(play=play, host=host)
            templar = Templar(playbook._loader, variables=host_vars)
            if s['failures'] > 0 or s['unreachable'] > 0:
                message_failed = host_vars.get('notify_webex_when_failed', None)
                if message_failed:
                    messages.append(templar.template(message_failed))
            else:
                message_success = host_vars.get(
                    'notify_webex_when_success', None)
                if message_success:
                    messages.append(templar.template(message_success))

        message_finished = play_vars.get('notify_webex_when_finished', None)
        messages.append(templar.template(message_finished))
        if messages:
            self.send_message("\n\n".join(messages))
```

## WebExへの通知
なんかAnsibleの話で全然WebEx Teamsの話は出てきませんでしたね... 
最後にWebEx Teamsに通知を送って終了です。
WebEx Teams APIに関しては結構ドキュメントがあると思うので割愛します...

```python
    WEBEX_TOKEN = 'YOURTOKENHERE'
    WEBEX_API_URL = 'https://api.ciscospark.com/v1/'

    def send_message(self, message):
        headers = {
            "Authorization": "Bearer {}".format(self.WEBEX_TOKEN),
            "Content-Type": 'application/json'
        }
        payload = {
            'toPersonEmail': self.destination,
            'markdown': message
        }
        data = json.dumps(payload)
        try:
            response = open_url(self.WEBEX_API_URL + '/messages',
                                headers=headers, data=data, validate_certs=False)
            return response.read()
        except Exception as e:
            self._display.warning(u'Could not submit message to Webex Team: %s' %
                                  to_text(e))
```

# 最後に
このCallback Pluginのサンプルは以下にあります。

https://github.com/oigarash-cisco/ansible_callback_plugin_to_webex_teams 

ちょっと手直しすればSxxckとかMxxxxsxxt Teamsとかでも自由に使えると思います。