Ansibleのcommand系ネットワークモジュールを自作した話

# はじめに
本記事では、Ansibleネットワークモジュールの内、showコマンド等を実行するための"xxx_command"モジュールに焦点を当てています。記事内で紹介している自作モジュールを、「このモジュールおすすめ！」と言いたかったんですが、まだまだという感じなので、自作モジュール作成の一例として読んでやって下さい！

# command系モジュールについて
Ansible 2.6以前では、`ios_command`、`nxos_command`、`junos_command`等、各ベンダーのOSタイプ固有のモジュールが存在していました。2.7からは、加えてOS非依存の`cli_command`がリリースされています。

各モジュールの特徴は以下の通りです。

| モジュール名       | モジュール |    Inventory内のグループ   |
|:-----------------|:------------------|:------------------|
| ios_command 等| OSタイプ毎に用意する必要あり | OSタイプ毎に分ける必要あり  |
| cli_command       | 一つでOK | **OSタイプ毎に分ける必要あり** |

`cli_command`の場合でも、OSの違いを全く意識しなくて良い訳ではなく、以下のようにInventory内で`ansible_network_os`を指定する必要があります。(調べた限り、サードパーティーが出しているマルチベンダー対応モジュールも同様でした。)

```yaml:inventory
[ios]
192.168.100.200

[ios:vars]
ansible_connection=network_cli
ansible_network_os=ios   #OSを指定
ansible_user=test
ansible_password=test

[nxos]
192.168.100.201

[nxos:vars]
ansible_connection=network_cli
ansible_network_os=nxos   #OSを指定
ansible_user=test
ansible_password=test
```

# 自作モジュールを作った背景

Cisco機器を扱うことが多く、実行するshowコマンドは同じなのに、OSタイプ(IOS、NX-OS、ASA、IOS-XR)毎にグループ分けするのはちょっと面倒だなーとか、不慣れなメンバーにとってはこれが若干ハードルかなーという思いがありました。
そこで、OSを指定せず、自動検出してコマンドを実行してくれるモジュールを作れないか、考えてみました。

別件でたまたま、`netmiko`というPythonライブラリの、デバイスタイプ(OS)自動検出機能を調査する機会があり、これをモジュールに組み込めば行けるかも！と思いました。

※netmikoや自動検出機能は、よこちさんのブログを参照頂ければと思います。
[netmikoでネットワーク機器の種類を自動検出してコマンドを実行する](https://qiita.com/akira6592/items/feff470e0093d0b7ca43)


# コマンド取得対象機器
以下の2種類の機器を用意しました。

| ホスト名       | 機種       | OSタイプ | バージョン |
|:-----------------|:-----------------|:------------------|:------------------|
| test | Cisco CSR1000v | IOS ※1 | 15.6(1)S2 |
| sbx-n9kv-ao | Cisco NX-OSv 9000 ※2 | NX-OS | 9.2(1) |

※1 厳密にはIOS-XEですが、Ansibleやnetmikoでの分類は「IOS」になります。
※2 今回は、Cisco DevNetのSandboxの一つである [Open NX-OS Programmability](https://devnetsandbox.cisco.com/RM/Diagram/Index/dae38dd8-e8ee-4d7c-a21c-6036bed7a804?diagramType=Topology)を利用させて頂きました。


# 自作モジュールの内容

モジュール名は、netmikoを使ったcommandモジュールということで、netmiko_commandにしようと思いましたが、正式モジュールではないので思い改め、`neko_command`としています:cat: (どうでもいい話ですね!)

モジュールはPythonで作成しており、大まかに以下の7つの処理を行っています。

1. InventoryファイルやVariableファイルで指定した値を取得し、変数に格納します。ここで、値の指定が必須な場合は`required=True`とします。オプションの場合は`required=False`とし、デフォルトで使う値を`default=`で指定します。また、Playbook実行結果に表示したくないパラメータは、`no_log=True`とします。
2. 指定した値の内、`command`(実行するshowコマンドを指定)については、文頭が「show」から始まっているかチェックします。チェック結果がNGの場合、メッセージを出力してFailとなるようにします。※必須ではないです。
3. netmikoを使ってSSHログインするための機器情報を指定。ここで、`device_type`は`autodetect`(自動検出)としておきます。
4. netmikoの`SSHDetect`を使い、デバイスタイプを自動検出。
5. 検出されたデバイスタイプ(`cisco_ios`、`cisco_nxos`等)を`device_type`に再度格納。
6. netmikoの`ConnectHandler`を使い、権限レベルに応じてenableパスワードを入力の上、`command`で指定したshowコマンドを実行。
7. 検出されたデバイスタイプ`best_match`とshowコマンド結果`stdout`を、`result`という辞書形式の変数に格納し、JSON形式に変換の上出力。

上記の内1(入力)と7(出力)は、多くのモジュールで共通かと思います(もちろんパラメータは違いますが)。また、今回は3～6でnetmikoを使用していますが、ログイン処理やコマンド取得はios_command等に含まれる既存ライブラリを用い、それ以降の処理を独自で実装する選択肢もあると思います。

```python:neko_command.py
from ansible.module_utils.basic import *
from netmiko import ConnectHandler
from netmiko.ssh_autodetect import SSHDetect


def main():
    '''
    Ansible module to get device type automatically and issue show command.
    '''
    # (1) store value from inventory file
    module = AnsibleModule(
        argument_spec=dict(
            host=dict(required=True),
            port=dict(default=22, required=False),
            username=dict(required=True),
            password=dict(required=True, no_log=True),
            secret=dict(required=False, default='', no_log=True),
            enable_mode=dict(required=False, default=False),
            command=dict(type='str', required=True),
        ),
        supports_check_mode=True
    )

    # (2) parse command
    if module.supports_check_mode and not module.params['command'].startswith('show'):
        module.fail_json(
            msg='Only show commands are supported when using check_mode, not '
            'executing %s' % module.params['command']
        )

    warnings = list()
    result = {'changed': False, 'warnings': warnings}

    # (3) define login information
    remote_device = {
        'device_type': 'autodetect',
        'ip': module.params['host'],
        'username': module.params['username'],
        'password': module.params['password'],
        'port' : module.params['port'],          # optional, defaults to 22
        'secret': module.params['secret'],     # optional, defaults to ''
        'verbose': False,       # optional, defaults to False
    }

    # (4) autodetect device type
    guesser = SSHDetect(**remote_device)
    best_match = guesser.autodetect()

    # (5) store the detected type in device_type
    remote_device['device_type'] = best_match

    # (6) issue show command
    connection = ConnectHandler(**remote_device)
    if module.params['enable_mode']:
        connection.enable()
    response = connection.send_command(module.params['command'])

    # (7) return the output of show command
    result['best_match'] = best_match
    result['stdout'] = response

    module.exit_json(**result)

    connection.disconnect()

if __name__ == "__main__":
    main()
```

次に、作成したPythonファイルを、モジュール実行用のロケーションに格納します。格納先は以下コマンドで確認可能です。

```text:
[centos@localhost ansible]$ ansible --version
ansible 2.7.0
  config file = /etc/ansible/ansible.cfg
  ansible python module location = /usr/lib/python2.7/site-packages/ansible  # ←ココ
  executable location = /usr/bin/ansible
  python version = 2.7.5 (default, Apr 11 2018, 07:36:10) [GCC 4.8.5 20150623 (Red Hat 4.8.5-28)]
```


# Inventoryファイル

ファイルの内容は以下の通りです。

- すべて同じグループに収容可能ですので、`all`グループにまとめて対象機器情報を記載しています。
- 認証方式が同じ場合、機器固有の環境変数として`[all]`内で指定するのは、ホスト情報くらいかと思います。今回は、Cisco DevNetを使っており、機器毎にユーザ名、パスワード、ポート番号が異なったため、個別に指定しています。
- グループ変数`[all:vars]`の内、`ansible_network_os`は何かしら入れないと、実行の最初のタイミングでAnsibleConnectionFailureエラーとなるため、ダミーの値として`junos`を指定しています。(イケてないですw)
- 今回の2台の機器は、ログイン時のenableパスワード入力は不要ですので、`ansible_become`や`ansible_become_pass`は省略しています。
- `ansible_command`で、取得コマンドとして「show version」を指定しています。


```text:inventory4
[all]
test ansible_host=192.168.100.200 ansible_user=test ansible_password=cisco ansible_port=22
sbx-n9kv-ao ansible_host=sbx-nxos-mgmt.cisco.com ansible_user=admin ansible_password=Admin_1234! ansible_port=8181

[all:vars]
ansible_network_os=junos
    # To avoid AnsibleConnectionFailure before executing neko_command module,
    # set 'junos' as a dummy value (any type of network os is OK.). 
ansible_connection=network_cli
ansible_command=show version
```

# Playbook

Playbookの内容は以下の通りです。

- `neko_command`モジュールで、Inventoryファイルで指定した値を元にshowコマンドを実行。その結果を`result`に格納。
- `debug`モジュールで、`result`に格納されているキー値 `best_match`および`stdout_lines`を出力。

```yaml:playbook1-5.yml
---

- hosts: all
  gather_facts: no

  tasks:
    - name: execute show command
      neko_command:
        host={{ ansible_host }}
        username={{ ansible_user }}
        password={{ ansible_password }}
        port={{ ansible_port }}
        command={{ ansible_command }}
      register: result

    - name: debug
      debug:
        msg:
          - "{{ result.best_match }}"
          - "{{ result.stdout_lines }}"
```

# Playbook実行

`netmiko`を使用するには、`pip intall netmiko` 等で事前にインストールを行う必要があります。
その上で、今回は`ansible-playbook -i inventory4 playbook1-5.yml`を実行します。


# 実行結果
ホスト`test`の`best_match`の値が一部*になっていますが、showコマンドは問題なく取得できました。
ただし、netmikoによる自動検出処理に時間がかかる影響で、1台の所要時間は30秒くらいでした。(ちょっと長い!)

```yaml:
[centos@localhost ansible]$ ansible-playbook -i inventory4 playbook1-5.yml

PLAY [all] *************************************************************************************************************************************

TASK [execute show command] ********************************************************************************************************************
ok: [test]
ok: [sbx-n9kv-ao]

TASK [debug] ***********************************************************************************************************************************
ok: [test] => {
    "msg": [
        "********_ios", 
        [
            "Cisco IOS XE Software, Version 03.17.02.S - Standard Support Release", 
            "Cisco IOS Software, CSR1000V Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 15.6(1)S2, RELEASE SOFTWARE (fc1)", 
            "Technical Support: http://www.********.com/techsupport", 
            "Copyright (c) 1986-2016 by Cisco Systems, Inc.", 
            "Compiled Mon 06-Jun-16 22:04 by mcpre", 
　　　　　　 ～ 中略 ～
        ]
    ]
}
ok: [sbx-n9kv-ao] => {
    "msg": [
        "cisco_nxos", 
        [
            "Cisco Nexus Operating System (NX-OS) Software", 
            "TAC support: http://www.cisco.com/tac", 
            "Documents: http://www.cisco.com/en/US/products/ps9372/tsd_products_support_series_home.html", 
            "Copyright (c) 2002-2018, Cisco Systems, Inc. All rights reserved.", 
            "The copyrights to certain works contained herein are owned by", 
            "other third parties and are used and distributed under license.", 
            "Some parts of this software are covered under the GNU Public", 
            "License. A copy of the license is available at", 
            "http://www.gnu.org/licenses/gpl.html.", 
            "", 
            "Nexus 9000v is a demo version of the Nexus Operating System", 
            "", 
            "Software", 
            "  BIOS: version ", 
            "  NXOS: version 9.2(1)", 
            "  BIOS compile time:  ", 
            "  NXOS image file is: bootflash:///nxos.9.2.1.bin", 
            "  NXOS compile time:  7/17/2018 16:00:00 [07/18/2018 00:21:19]", 
　　　　　　 ～ 中略 ～
        ]
    ]
}

PLAY RECAP *************************************************************************************************************************************
sbx-n9kv-ao                : ok=2    changed=0    unreachable=0    failed=0   
test                       : ok=2    changed=0    unreachable=0    failed=0   

[centos@localhost ansible]$ 
```

# 失敗談

当初、自作モジュールの出力を`best_match`(自動検出したOSタイプ)とし、それをPlaybook内に定義した変数`ansible_network_os`に格納して、後続の`cli_command`や`cli_config`を使ったタスク実行時に読み込ませることを考えましたが、うまくいきませんでした。
Connectionプラグインとそれに紐づく`ansible_network_os`は、Playbook開始時に読み込まれるため、途中で値を変えてもダメなようです。


# 最後に
一応動くものは作れました。`ansible_network_os`でダミー値を使ったり、処理時間がかかったりする点はありますが、急ぎではない時、ちょっとshow versionを取りたいなという時には使えそうです。
今後のAnsibleバージョンアップの中で、`ansible_network_os`で`autodetect`を指定(もしくは何も指定しない)場合、自動検出してくれる機能が実装されれば、もっと便利な世の中になるのになーと思う今日この頃でした。

～ 最後まで読んでくれてありがとにゃ:cat: ～
![11-1.png](https://qiita-image-store.s3.amazonaws.com/0/310082/017776c2-c4c0-f707-8f9d-50b2d8d6a7c2.png)

# 2018/12/16 追記
この話のモヤモヤは、続編 [Ansibleのcommand系ネットワークモジュールを自作した話の続き](https://qiita.com/tech_kitara/items/0d5f045628c5b5f6cecb) で一部解決したにゃ！