Nornir3によるネットワーク自動化 ①セットアップ、showコマンド実行編

# 1. Nornirとは
自動化フレームワークの1つで、直接Pythonコードを書くことで機器への接続、コマンド実行、設定変更、テスト等を自動化できます。

2021/1/2時点の最新版は3.0.0です。2点台ではプラグインを含めて一つのパッケージになっていましたが、3点台からは必要なものを個別にインストールする形になりました。
プラグインの例として、Inventory処理やデータ加工・出力処理等を行う`nornir_utils`、機器への接続や各種操作を行う`nornir_netmiko`、`nornir_napalm`、`nornir_scrapli`、テンプレートからConfig生成等を行うための`nornir_jinja2`、AnsibleのInventoryファイルをNornirで使うための`nornir_ansible`等があります。

今回は、`nornir_netmiko`プラグインでCisco機器に接続し、showコマンド実行と出力結果のパース、簡単なテストを行ってみました。


# 2. セットアップ
### 2-1. NW構成
Cisco CML-P 2.1の仮想環境上にIOS機器`iosvl2-0`とNX-OS機器`nxos-0`を構築し、2台の間でHSRPを組んでいます(ちょっと特殊ですがw)。
また上記2台と外部環境のCentOSを同一セグメント内に接続し、次項以降のソフトウェアをインストールしています。
![無題0102-01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/310082/0890a233-f7a4-b481-8d4a-193d4965041d.png)

### 2-2. Python
Nornirを使うには3.6.2以上が必要です。今回は仮想環境(venv)上に3.6.8をインストールしました。

### 2-3. Nornir + Plugins
以下最低限のものだけインストールしました。

```
pip install nornir
pip install nornir_utils
pip install nornir_scrapli
pip install nornir_netmiko
```

### 2-4. パーサー
TextFSM(NTC-Templates)とpyATSの2種類を使ってみます。前者は2-3.で一緒にインストールされたため、pyATSのみ追加インストールしました。
`pip install pyats`だとpyATSのコア部分しかインストールされないため、以下の通りパーサー等のpyATSライブラリを含めるオプション指定が必要です。

```
pip install pyats[library]
```
(参考) パース可能なshowコマンド一覧
[NTC-Templates](https://github.com/networktocode/ntc-templates/tree/master/templates)
[pyATS](https://pubhub.devnetcloud.com/media/genie-feature-browser/docs/#/parsers)


# 3. Inventoryの作成
まず、機器へのログイン情報や実行コマンド等を指定するファイル群を作成します。Ansibleで言うところのInventoryファイルや変数定義ファイルに相当するかと思います。
プラグインとして`SimpleInventory`を用いる場合、大きく3種類のファイルがあります。

### 3-1. hostsファイル
各機器固有の変数を定義するファイルです。
ここではログインIPアドレス/ポート番号、プラットフォーム、所属グループ、実行コマンド、HSRPの想定結果を指定しています。
プラットフォームは、NetmikoやNAPALMからNW機器に接続する際、OSやプロトコル毎に用意されているドライバーを指定するための変数です。
具体的には、`iosvl2-0`で指定しているプラットフォーム`cisco_ios_telnet`は、NetmikoからIOSへTelnet接続するためのドライバー名のことです。
一方`nxos-0`で指定している`cisco_nxos`は、NetmikoからNX-OSへSSH接続するためのドライバー名です。
AnsibleやNAPALMでは、同様の用途で`cisco_ios`や`cisco_nxos`の代わりに`ios`や`nxos`という名前が使われますが、`nornir_netmiko`プラグイン内で2つの対応関係がマッピングされており、どちらを使っても問題なくログイン可能です。
所属グループは、次項のgroupsファイルとの紐付けに使用されます。
実行コマンドは、HSRPの確認コマンドが機種によって異なるためここで定義しています。
HSRPの想定結果は、最後のテストで使用します。

```yaml:hosts.yaml
---
iosvl2-0:
  hostname: 192.168.100.55
  port: 23
  platform: cisco_ios_telnet
  groups:
    - ios
  data:
    command: show standby all
    hsrp:
      priority: 120
      state: active

nxos-0:
  hostname: 192.168.100.56
  port: 22
  platform: cisco_nxos
  groups:
    - nxos
  data:
    command: show hsrp all
    hsrp:
      priority: 110
      state: standby
```

### 3-2. groupsファイル
グループ共通の変数を定義するファイルです。
ここではログインユーザ名/パスワード、enableパスワードを指定しています。
enableパスワードの書き方が少し特殊で、以下の通りNetmiko用の`extras`キー内で定義します。

```yaml:groups.yaml
---
ios:
  username: cisco
  password: cisco
  connection_options:
    netmiko:
      extras:
        secret: cisco

nxos:
  username: admin
  password: cisco
```

### 3-3. defaultsファイル
デフォルトで使用する変数を定義するファイルです。
ここでは全機器で共通して使う、NTPサーバ設定コマンドを指定しています。
機器によって例外がある場合は、defaultsファイルに定義しつつ、hosts、groupsファイルで値を上書きする使い方も出来ます。

```yaml:defaults.yaml
---
data:
  ntp_server:
    - 1.1.1.1
    - 2.2.2.2
```

# 4. Configファイル
Inventoryの保管パスや同時接続数等の設定情報を指定するファイルです。

```yaml:config.yaml
---
inventory:
    plugin: SimpleInventory
    options:
        host_file: "inventory/hosts.yaml"
        group_file: "inventory/groups.yaml"
        defaults_file: "inventory/defaults.yaml"

runner:
    plugin: threaded
    options:
        num_workers: 30
```

# 5. Tasks (Pythonコード) と実行結果

## 5-1. 例1: showコマンド実行
2台の機器へコマンド`show version`を実行する例です。

```python:nornir11.py
from nornir import InitNornir
from nornir.core.task import Task, Result
from nornir_utils.plugins.functions import print_result
from nornir_netmiko.tasks import netmiko_send_command

# 初期化
nr = InitNornir(config_file="config.yaml")

# タスクを実行し、result変数に格納
result = nr.run(
    # 実行内容に任意で名前を付けられる。指定しない場合はタスク名が使用される
    name="run show command",
    # nornir_netmikoプラグイン内のコマンド実行用関数を指定
    task=netmiko_send_command,
    # enableモードに昇格するか。デフォルトはFalseのため、showコマンドによっては明示的にTrue指定が必要
    enable=True,
    # 実行コマンド(str型)
    command_string="show version"
    )

# 実行結果を表示
print_result(result)
```

#### 実行結果
途中省略していますが、2台とも問題なくshowコマンド結果が表示されました。

```
$ python nornir11.py
run show command****************************************************************
* iosvl2-0 ** changed : False **************************************************
vvvv run show command ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO

Cisco IOS Software, vios_l2 Software (vios_l2-ADVENTERPRISEK9-M), Version 15.2(CML_NIGHTLY_20190423)FLO_DSGS7, EARLY DEPLOYMENT DEVELOPMENT BUILD, synced to  V152_6_0_81_E
(省略)

^^^^ END run show command ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* nxos-0 ** changed : False ****************************************************
vvvv run show command ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
Cisco Nexus Operating System (NX-OS) Software
(省略)

^^^^ END run show command ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
```

## 5-2. 例2: showコマンド実行 + TextFSM + 実行結果限定
2台の機器へコマンド`show version`を実行し、TextFSMでパースした結果を出力する例です。
実行結果は`nxos-0`だけに絞ってみました。

```python:nornir12.py
from nornir import InitNornir
from nornir.core.task import Task, Result
from nornir_utils.plugins.functions import print_result
from nornir_netmiko.tasks import netmiko_send_command

nr = InitNornir(config_file="config.yaml")

result = nr.run(
    name="run show command",
    task=netmiko_send_command,
    enable=True,
    command_string="show version",
    # TextFSM使用オプションをTrueに指定
    use_textfsm=True
    )

# nxos-0の実行結果のみ表示
print_result(result["nxos-0"][0])
```

#### 実行結果
`nxos-0`のパース結果が表示されました。

```
$ python nornir12.py
---- nxos-0: run show command ** changed : False ------------------------------- INFO
[ { 'boot_image': 'bootflash:///titanium-d1-kickstart.7.3.0.D1.1.bin',
    'hostname': 'nxos-0',
    'last_reboot_reason': '',
    'os': '7.3(0)D1(1)',
    'platform': 'OSv',
    'uptime': '0 day(s), 4 hour(s), 18 minute(s), 23 second(s)'}]
```
ちなみに結果は、リストオブジェクトに辞書がネストされたデータに見えますが、Resultという独自のオブジェクトです。リストや辞書のようにイテラブルではないため、`result["nxos-0"][0][0]["os"]`のように指定しても「TypeError: 'Result' object does not support indexing」で失敗しました。


## 5-3. 例3: hostsファイルのコマンド実行 + pyATS + HSRPステータス判定
hostsファイルで定義したHSRPのshowコマンドを実行し、pyATSでパースした結果を出力します。
さらにパース結果からHSRPのプライオリティ値とステータス(Active/Standy)が想定通りか判定してみます。
ちなみにHSRPコマンドのパース結果は階層が深くなっており、通常の辞書データであれば`data.result["階層1"]["階層2"]["階層3"]["階層4"]["階層5"]...['priority']`のように書く必要がありますが、pyATSのDq(Data Query)を使うことで`Dq(data.result).get_values('priority')`とシンプルな記述になっています:relaxed:

```python:nornir14.py
from nornir import InitNornir
from nornir.core.task import Task, Result
from nornir_utils.plugins.functions import print_result
from nornir_netmiko.tasks import netmiko_send_command
from genie.utils import Dq


def verify_hsrp_status(task: Task) -> Result:
    data = task.run(
        name="run show command from hosts file and parse the output",
        task=netmiko_send_command,
        enable=True,
        # hostsファイルからshowコマンドを取得
        command_string=nr.inventory.hosts[task.host.name]["command"],
        # pyATS使用オプションをTrueに指定
        use_genie=True
    )
    # hostsファイルで定義したHSRPプライオリティの想定値と実際の値が同じかどうか判定
    assert task.host["hsrp"]["priority"] == Dq(data.result).get_values('priority')[0]
    # hostsファイルで定義したHSRPステータスの想定値と実際の値が同じかどうか判定
    assert task.host["hsrp"]["state"] == Dq(data.result).get_values('hsrp_router_state')[0]

nr = InitNornir(config_file="config.yaml")

# 上記のカスタムタスクを実行し、結果をresultに格納
result = nr.run(
    task=verify_hsrp_status
    )

# 実行結果を表示
print_result(result)

# 失敗したホストを表示
print(result.failed_hosts)
```

### 実行結果
いずれもエラーなしで問題なく完了しました。

```
$ python nornir14.py
verify_hsrp_status**************************************************************
* iosvl2-0 ** changed : False **************************************************
vvvv verify_hsrp_status ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
---- run show command from hosts file and parse the output ** changed : False -- INFO
{ 'GigabitEthernet0/0': { 'address_family': { 'ipv4': { 'version': { 1: { 'groups': { 1: { 'active_router': 'local',
                                                                                           'configured_priority': 120,
                                                                                           'group_number': 1,
                                                                                           'hsrp_router_state': 'active',
                                                                                           'local_virtual_mac_address': '0000.0c07.ac01',
                                                                                           'local_virtual_mac_address_conf': 'v1 '
                                                                                                                             'default',
                                                                                           'preempt': True,
                                                                                           'primary_ipv4_address': { 'address': '192.168.100.54'},
                                                                                           'priority': 120,
                                                                                           'session_name': 'hsrp-Gi0/0-1',
                                                                                           'standby_expires_in': 11.344,
                                                                                           'standby_ip_address': '192.168.100.56',
                                                                                           'standby_priority': 110,
                                                                                           'standby_router': '192.168.100.56',
                                                                                           'timers': { 'hello_msec_flag': False,
                                                                                                       'hello_sec': 3,
                                                                                                       'hold_msec_flag': False,
                                                                                                       'hold_sec': 10,
                                                                                                       'next_hello_sent': 2.368},
                                                                                           'virtual_mac_address': '0000.0c07.ac01',
                                                                                           'virtual_mac_address_mac_in_use': True}}}}}},
                          'interface': 'GigabitEthernet0/0',
                          'redirects_disable': False,
                          'use_bia': False}}
^^^^ END verify_hsrp_status ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* nxos-0 ** changed : False ****************************************************
vvvv verify_hsrp_status ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
---- run show command from hosts file and parse the output ** changed : False -- INFO
{ 'Ethernet2/1': { 'address_family': { 'ipv4': { 'version': { 1: { 'groups': { 1: { 'active_expire': 1.521,
                                                                                    'active_ip_address': '192.168.100.55',
                                                                                    'active_priority': 120,
                                                                                    'active_router': '192.168.100.55',
                                                                                    'authentication': 'cisco',
                                                                                    'configured_priority': 110,
                                                                                    'group_number': 1,
                                                                                    'hsrp_router_state': 'standby',
                                                                                    'last_state_change': '07:09:13',
                                                                                    'lower_fwd_threshold': 1,
                                                                                    'num_state_changes': 4,
                                                                                    'primary_ipv4_address': { 'address': '192.168.100.54',
                                                                                                              'virtual_ip_learn': False},
                                                                                    'priority': 110,
                                                                                    'session_name': 'hsrp-Eth2/1-1',
                                                                                    'standby_priority': 110,
                                                                                    'standby_router': 'local',
                                                                                    'timers': { 'hello_msec_flag': False,
                                                                                                'hello_sec': 3,
                                                                                                'hold_msec_flag': False,
                                                                                                'hold_sec': 10},
                                                                                    'upper_fwd_threshold': 110,
                                                                                    'virtual_mac_address': '0000.0c07.ac01',
                                                                                    'virtual_mac_address_status': 'default'}}}}}},
                   'interface': 'Ethernet2/1',
                   'use_bia': False}}
^^^^ END verify_hsrp_status ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
{}
```

`iosvl2-0`のHSRPプライオリティ値を120から100に変更し、Active/Standby切替を発生させた時の結果も貼り付けておきます。
verify_hsrp_statusがERRORとなり、`AssertionError`の例外処理が走っています。

```
$ python nornir14.py
verify_hsrp_status**************************************************************
* iosvl2-0 ** changed : False **************************************************
vvvv verify_hsrp_status ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv ERROR
Traceback (most recent call last):
  File "/home/centos/venv_nornir3/lib64/python3.6/site-packages/nornir/core/task.py", line 98, in start
    r = self.task(self, **self.params)
  File "nornir14.py", line 19, in verify_hsrp_status
    assert task.host["hsrp"]["priority"] == Dq(data.result).get_values('priority')[0]
AssertionError

---- run show command from hosts file and parse the output ** changed : False -- INFO
{ 'GigabitEthernet0/0': { 'address_family': { 'ipv4': { 'version': { 1: { 'groups': { 1: { 'active_expires_in': 9.648,
                                                                                           'active_ip_address': '192.168.100.56',
                                                                                           'active_router': '192.168.100.56',
                                                                                           'active_router_priority': 110,
                                                                                           'default_priority': 100,
                                                                                           'group_number': 1,
                                                                                           'hsrp_router_state': 'standby',
                                                                                           'local_virtual_mac_address': '0000.0c07.ac01',
                                                                                           'local_virtual_mac_address_conf': 'v1 '
                                                                                                                             'default',
                                                                                           'preempt': True,
                                                                                           'primary_ipv4_address': { 'address': '192.168.100.54'},
                                                                                           'priority': 100,
                                                                                           'session_name': 'hsrp-Gi0/0-1',
                                                                                           'standby_router': 'local',
                                                                                           'timers': { 'hello_msec_flag': False,
                                                                                                       'hello_sec': 3,
                                                                                                       'hold_msec_flag': False,
                                                                                                       'hold_sec': 10,
                                                                                                       'next_hello_sent': 0.912},
                                                                                           'virtual_mac_address': '0000.0c07.ac01',
                                                                                           'virtual_mac_address_mac_in_use': False}}}}}},
                          'interface': 'GigabitEthernet0/0',
                          'redirects_disable': False,
                          'use_bia': False}}
^^^^ END verify_hsrp_status ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* nxos-0 ** changed : False ****************************************************
vvvv verify_hsrp_status ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv ERROR
Traceback (most recent call last):
  File "/home/centos/venv_nornir3/lib64/python3.6/site-packages/nornir/core/task.py", line 98, in start
    r = self.task(self, **self.params)
  File "nornir14.py", line 21, in verify_hsrp_status
    assert task.host["hsrp"]["state"] == Dq(data.result).get_values('hsrp_router_state')[0]
AssertionError

---- run show command from hosts file and parse the output ** changed : False -- INFO
{ 'Ethernet2/1': { 'address_family': { 'ipv4': { 'version': { 1: { 'groups': { 1: { 'active_priority': 110,
                                                                                    'active_router': 'local',
                                                                                    'authentication': 'cisco',
                                                                                    'configured_priority': 110,
                                                                                    'group_number': 1,
                                                                                    'hsrp_router_state': 'active',
                                                                                    'last_state_change': '00:03:44',
                                                                                    'lower_fwd_threshold': 1,
                                                                                    'num_state_changes': 5,
                                                                                    'primary_ipv4_address': { 'address': '192.168.100.54',
                                                                                                              'virtual_ip_learn': False},
                                                                                    'priority': 110,
                                                                                    'session_name': 'hsrp-Eth2/1-1',
                                                                                    'standby_expire': 0.676,
                                                                                    'standby_ip_address': '192.168.100.55',
                                                                                    'standby_priority': 100,
                                                                                    'standby_router': '192.168.100.55',
                                                                                    'timers': { 'hello_msec_flag': False,
                                                                                                'hello_sec': 3,
                                                                                                'hold_msec_flag': False,
                                                                                                'hold_sec': 10},
                                                                                    'upper_fwd_threshold': 110,
                                                                                    'virtual_mac_address': '0000.0c07.ac01',
                                                                                    'virtual_mac_address_status': 'default'}}}}}},
                   'interface': 'Ethernet2/1',
                   'use_bia': False}}
^^^^ END verify_hsrp_status ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
{'iosvl2-0': MultiResult: [Result: "verify_hsrp_status", Result: "run show command from hosts file and parse the output"], 'nxos-0': MultiResult: [Result: "verify_hsrp_status", Result: "run show command from hosts file and parse the output"]}
```

# 最後に
まだ触り始めたばかりですが、AnsibleのようなInventoryの仕組みを持ちつつ、他のPythonベースの自動化ツールとの連携のしやすさ、マルチベンダ対応、Pythonでデータ加工や繰り返し処理が出来る点から、個人的に重宝しそうだと感じました。ただし、Nornirのコードの書き方に加え、PythonやベースとなるNetmikoやNAPALM等の知識が必要なので、学習コストは比較的高めなのかもしれません。

# 参考URL
- 公式サイト
[nornir - Read the Docs](https://nornir.readthedocs.io/en/latest/index.html)
[Nornir Plugins](https://nornir.tech/nornir/plugins/)
[GitHub - nornir_netmiko](https://github.com/ktbyers/nornir_netmiko)

- ブログ記事等
[An Introduction to Nornir](https://pynet.twb-tech.com/blog/nornir/intro.html)
[[nornir] Python 製自動化フレームワーク「nornir」かんたんチュートリアル（Ansibleと比較しながら）](https://tekunabe.hatenablog.jp/entry/2018/10/16/nornir_intro)
[[nornir] 指定できるプラットフォーム名（ios / junos / eos / vyos など）](https://tekunabe.hatenablog.jp/entry/2018/12/18/nornir_platform)
[Nornir 3.0の注意点とCisco IOS XE ホスト名の取得](https://qiita.com/katu7414/items/701f1fef95a2dd755250)
[Passing enable secret](https://nornir.discourse.group/t/passing-enable-secret/16)
[pyATS と Python辞書 が楽しくなる Dq(Data Query) を極める!!!](https://qiita.com/tahigash/items/2776b00e97a416b98e3d)