ExcelのヒアリングシートからAnsibleでZabbixに監視テンプレートを登録する

## はじめに

監視設計は専任ではなく、開発に関わる人は誰でも行えることが望ましいですが、監視ツールのひとつである Zabbix は慣れていないと設定が難しい面があると思います。
そこで、Excelのヒアリングシートから Ansible で Zabbix に監視テンプレートを登録できるようにしてみます。
なお、本記事は [Ansible Advent Calendar 2020](https://qiita.com/advent-calendar/2020/ansible_01) の 12/10 の記事です。

## 前提とする環境

以下の環境で動作確認しました。

* CentOS 8.2
* Python 3.6.8
    * zabbix-api 0.5.4
* Ansible 2.10.3

環境構築手順は以下の通りです。[[Ansible] CentOS 8 に Ansible をインストールする（Python 3 + venv + pip） - てくなべ (tekunabe)](https://tekunabe.hatenablog.jp/entry/2019/10/06/ansible_centos8_python3_pip) の手順のままですので、解説はそちらをご覧ください。

```
sudo dnf install python3 -y
python3 -m venv ansible-py3
source ansible-py3/bin/activate
pip3 install ansible
pip3 install zabbix-api
```

## zabbix_template モジュールを読む

Ansible の zabbix_template モジュールがどんなオプションを指定して Zabbix API に投げているのか気になったので、ソースを読んでみました。

```~/ansible-py3/lib/python3.6/site-packages/ansible_collections/community/zabbix/plugins/modules/zabbix_template.py
    def import_template(self, template_content, template_type='json'):
        if self._module.check_mode:
            self._module.exit_json(changed=True)

        # rules schema latest version
        update_rules = {
            'applications': {
                'createMissing': True,
                'deleteMissing': True
            },
            'discoveryRules': {
                'createMissing': True,
                'updateExisting': True,
                'deleteMissing': True
            },
            'graphs': {
                'createMissing': True,
                'updateExisting': True,
                'deleteMissing': True
            },
            'groups': {
                'createMissing': True
            },
            'httptests': {
                'createMissing': True,
                'updateExisting': True,
                'deleteMissing': True
            },
            'items': {
                'createMissing': True,
                'updateExisting': True,
                'deleteMissing': True
            },
            'templates': {
                'createMissing': True,
                'updateExisting': True
            },
            'templateLinkage': {
                'createMissing': True
            },
            'templateScreens': {
                'createMissing': True,
                'updateExisting': True,
                'deleteMissing': True
            },
            'triggers': {
                'createMissing': True,
                'updateExisting': True,
                'deleteMissing': True
            },
            'valueMaps': {
                'createMissing': True,
                'updateExisting': True
            }
        }
(中略)
            import_data = {'format': template_type, 'source': template_content, 'rules': update_rules}
            self._zapi.configuration.import_(import_data)
```

テンプレートをインポートする時は、[configuration.import](https://www.zabbix.com/documentation/5.0/manual/api/reference/configuration/import) を利用していて、「deleteMissing」を True に指定しています。このオプションはデフォルトではないのですが、これがないと、インポート先のテンプレートが存在する場合、前の設定が残ってしまい、削除したはずのアイテムやトリガーが残ってる!? という事態になってしまいます。

## Excelヒアリングシートから Zabbix テンプレートをインポートするまでの流れ

以下の流れで処理します。

1. Excelヒアリングシートからコピペしてインポート用の情報をTSV形式で書く (手動)
2. 1. で作成した TSV ファイルを読み、インポート用のXMLファイルを生成する (Ansible)
3. 2. で生成した XML ファイルをインポートする (Ansible)

## Excelヒアリングシート

図のように入力項目に記入すると、別シートにある情報から VLOOKUP 関数を使用して、自動入力項目にTSV形式に取り込みたい情報が入力されるようにしました。

![excel1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/56678/6ebada47-88cd-cf2d-03eb-da466daae733.png)
↓ 上の表に入力すると、下の表が生成される
![excel2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/56678/09b332f1-5c6e-4540-7110-a951d76947e7.png)

## Playbook の構成

以下のディレクトリ・ファイル構成にします。

```
zbx_tsv2xml.yml ............ Playbook 本体
_make_template.yml ......... インポート用 XML ファイルを生成する Playbook
_import_template.yml ....... Zabbix に XML ファイルをインポートする Playbook
input/ ..................... 入力情報格納用ディレクトリ
  items_<Template名>.tsv ... テンプレート毎のアイテム情報
  tmpl_list.tsv ............ インポートしたいテンプレート一覧
output/ .................... インポート用 XML ファイル格納用ディレクトリ
templates/ ................. インポート用 XML ファイルのテンプレート格納用ディレクトリ
  tmpl.xml.j2 .............. インポート用 XML ファイルのテンプレート
```

### input/items_《Template名》.tsv

テンプレート毎のアイテム情報を定義します。１行１アイテムを定義し、そのアイテムに紐づくトリガーも定義できるようになっています。

```items_Template-IntPort-22-sshd.tsv
name	type	value_type	value_map	key	applications	trigger_name	expression	priority
TCP Port [$3] (Internal)	ZABBIX_PASSIVE	UNSIGNED	Service state	net.tcp.service[tcp,127.0.0.1,22]	HealthCheck	{$DESCRIPTION} Port 22(sshd) down (1m)	{count(#1,0,"eq")}=1	WARNING
TCP Port [$3] (Internal)	ZABBIX_PASSIVE	UNSIGNED	Service state	net.tcp.service[tcp,127.0.0.1,22]	HealthCheck	{$DESCRIPTION} Port 22(sshd) down (3m)	{count(#3,0,"eq")}=3	HIGH
```

### input/tmpl_list.tsv

インポートしたいテンプレート一覧を定義します。

```
template_name	groups	description	applications
Template-IntPort-22-sshd	TemplateG-Custom	TCP内部ポート監視	HealthCheck
```

### zbx_tsv2xml.yml

Playbook 本体です。 `tmpl_list.tsv` を読み込み、 `_make_template.yml` で１行ずつループしながらアイテムの情報を読み込み、テンプレートを出力します。その後、 `_import_template.yml` で出力したテンプレートを Zabbix にインポートします。

```
- hosts: localhost
  connection: local
  gather_facts: false

  tasks:
    - name: read tmpl list file
      read_csv:
        path: input/tmpl_list.tsv
        delimiter: "\t"
      register: tmpl_list
      delegate_to: localhost

    - name: make template
      include: _make_template.yml
      with_items: "{{ tmpl_list.list }}"

    - name: Import Zabbix templates from XML
      include: _import_template.yml
      with_items: "{{ tmpl_list.list }}"
```

### _make_template.yml

テンプレートを出力します。ファイル名はテンプレート名から生成できるようにしました。（半角空白をアンダーラインに置換しています）

```
---
- name: read CSV file
  read_csv:
    path: input/items_{{ item.template_name | regex_replace(' ','_') }}.tsv
    delimiter: "\t"
  register: items
  delegate_to: localhost

- name: export template file
  template:
    src: templates/tmpl.xml.j2
    dest: "output/{{ item.template_name | regex_replace(' ','_') }}.xml"
    mode: 0644
```

### _import_template.yml

出力したテンプレートを Zabbix にインポートします。
今回は PoC なので、Zabbix URL や認証情報を埋め込みにしていますが、実際に運用する場合は、 vars で定義するとよいでしょう。

```
---
- name: determine Zabbix template file
  set_fact:
    import_file: "output/{{ item.template_name | regex_replace(' ','_') }}.xml"

- name: set import xml
  set_fact:
    import_xml: "{{ lookup('file', import_file) }}"

- name: "Import Zabbix templates from XML ({{ import_file }})"
  local_action:
    module: zabbix_template
    server_url: http://192.168.33.11/zabbix
    login_user: Admin
    login_password: zabbix
    template_xml: "{{ import_xml }}"
    state: present
```

### templates/tmpl.xml.j2

テンプレートの書式は、[Zabbix5.0 のテンプレートを覗いてみた](https://qiita.com/xotaki/items/c5228afab20bb0fb482c) の記事を参照してください。

```
<?xml version="1.0" encoding="UTF-8"?>
<zabbix_export>
    <version>5.0</version>
    <date>{{ now(False,"%Y-%m-%dT%H:%M:%SZ") }}</date>
    <groups>
{% for group in item.groups.split(',') %}
        <group>
            <name>{{ group }}</name>
        </group>
{% endfor %}
    </groups>
    <templates>
        <template>
            <template>{{ item.template_name }}</template>
            <name>{{ item.template_name }}</name>
            <description/>
            <groups>
{% for group in item.groups.split(',') %}
                <group>
                    <name>{{ group }}</name>
                </group>
{% endfor %}
            </groups>
            <applications>
{% for application in item.applications.split(',') %}
                <application>
                    <name>{{ application }}</name>
                </application>
{% endfor %}
            </applications>
            <items>
{% for it in items.list %}
                <item>
                    <name>{{ it.name }}</name>
                    <key>{{ it.key }}</key>
                    <delay>60</delay>
{% if it.value_type == 'LOG' %}
                    <history>60d</history>
                    <trends>0</trends>
{% else %}
                    <history>30d</history>
                    <trends>365d</trends>
{% endif %}
                    <value_type>{{ it.value_type }}</value_type>
                    <applications>
{% for application in it.applications.split(',') %}
                        <application>
                            <name>{{ application }}</name>
                        </application>
{% endfor %}
                    </applications>
{% if it.value_map != "0" %}
                    <valuemap>
                        <name>{{ it.value_map }}</name>
                    </valuemap>
{% endif %}
{% if it.expression is defined %}
                    <triggers>
                        <trigger>
                            <expression>{{ it.expression | e }}</expression>
                            <name>{{ it.trigger_name }}</name>
                            <priority>{{ it.priority }}</priority>
                        </trigger>
                    </triggers>
{% endif %}
                </item>
{% endfor %}
            </items>
            <macros/>
            <templates/>
            <screens/>
        </template>
    </templates>
    <value_maps>
        <value_map>
            <name>Service state</name>
            <mappings>
                <mapping>
                    <value>0</value>
                    <newvalue>Down</newvalue>
                </mapping>
                <mapping>
                    <value>1</value>
                    <newvalue>Up</newvalue>
                </mapping>
            </mappings>
        </value_map>
    </value_maps>
</zabbix_export>
```

## 実行結果

それでは、実行してみます。

```
(ansible-py3) [vagrant@ansible zbx_tsv2xml]$ ansible-playbook zbx_tsv2xml.yml
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all'

PLAY [localhost] ************************************************************************************************************************

TASK [read tmpl list file] **************************************************************************************************************
ok: [localhost]

TASK [make template] ********************************************************************************************************************
included: /home/vagrant/zbx_tsv2xml/_make_template.yml for localhost => (item={'template_name': 'Template-IntPort-22-sshd', 'groups': 'TemplateG-RegaliaPtl', 'description': 'TCP内部ポート監視', 'applications': 'HealthCheck'})

TASK [read CSV file] ********************************************************************************************************************
ok: [localhost]

TASK [export template file] *************************************************************************************************************
changed: [localhost]

TASK [Import Zabbix templates from XML] *************************************************************************************************
included: /home/vagrant/zbx_tsv2xml/_import_template.yml for localhost => (item={'template_name': 'Template-IntPort-22-sshd', 'groups': 'TemplateG-Custom', 'description': 'TCP内部ポート監視', 'applications': 'HealthCheck'})

TASK [determine Zabbix template file] ***************************************************************************************************
ok: [localhost]

TASK [set import xml] *******************************************************************************************************************
ok: [localhost]

TASK [Import Zabbix templates from XML (output/Template-IntPort-22-sshd.xml)] ***********************************************************
changed: [localhost]

PLAY RECAP ******************************************************************************************************************************
localhost                  : ok=9    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
```

無事に `Template-IntPort-22-sshd` というテンプレートがインポートされました。

## (おまけ) 冪等性

ちなみに、上記の playbook は、何度実行してもインポートする処理が必ず changed になってしまいます。
先ほどの zabbix-template.py を読むと、冪等性を担保するために設定前後の比較をする処理がありました。

```
        if template_content is not None and template_type == 'json':
            parsed_template_json = self.load_json_template(template_content)
            if self.diff_template(parsed_template_json, existing_template):
                changed = True

            return changed
(中略)
    def diff_template(self, template_json_a, template_json_b):
        # Compare 2 zabbix templates and return True if they differ.
        template_json_a = self.filter_template(template_json_a)
        template_json_b = self.filter_template(template_json_b)
        if self.ordered_json(template_json_a) == self.ordered_json(template_json_b):
            return False
        return True
```

JSON 形式の場合にのみ比較をするようになっています。
Zabbixの管理画面からエクスポートすると XML なので、XML形式でインポートすることにしましたが、 JSON 形式のほうが良かったのかもしれません。