pyATSによるCisco IOS Routerのテスト例

#はじめに
pyATSは、pythonベースのテストフレームワークです。基本的なことは、[pyATSの初歩](https://qiita.com/tokatsu/items/58fbd2b2b219f33b8721) に記載しています。
その記事にも書いたように、pyATS の特徴として、ネットワーク機器にアクセスしてコマンドを実行したり、その結果を取得する Classを含んでいることです。
[IOS Routerにアクセスするサンプルコード](https://github.com/CiscoDevNet/pyats-ios-sample.git) があったので、それを基に、pATSの機器アクセスの機能について解説したいと思います。
# 環境について
以下のような環境で試験しました。
* CentOS7
* python 3.4.5
* pyATS 4.0.0
* ルータは、csr1000v + IOS-XE 16.3.5 をkvm上で動かしています。
# Connection Class
[pyATSの公式ドキュメント](https://developer.cisco.com/docs/pyats/) を見ると、実際にネットワーク機器に対して仕事をする Class は、Connection Class になるようです。しかしながら、このドキュメントには、Connection Class の実装例が記述されているだけで、具体的な Connection Class については記述がないです。
最初は、自分で Connection Class のコードを書かないと、ネットワーク機器の操作はできないのかと思いました。
[IOS Routerにアクセスするサンプルコード](https://github.com/CiscoDevNet/pyats-ios-sample.git) をみつけて、内容を理解したところ、どうやら unicon というモジュールが標準で pyATS に含まれていて、これを使えば、IOS Router に対してアクセスができるようです。~~ただ、unicon に関する情報は少なく、IOS 以外の機器に対応しているかどうかなどは不明です。~~
Uniconに関するdocumentが公開されていました。
[Unicon Connection Library](https://pubhub.devnetcloud.com/media/pyats-packages/docs/unicon/index.html#unicon-connection-library)

また、Connection Classは、YAML で記述した Testbed ファイルをサポートしているので、Testbed ファイルに各機器へのアクセス情報を記述しておけば、テストスクリプトの中では、デバイス名をしてするだけで機器へのアクセスが可能になります。
#サンプルコードの概要
[IOS Routerにアクセスするサンプルコード](https://github.com/CiscoDevNet/pyats-ios-sample.git) の Readme にも書いてありますが、以下のようなテストを行います。
*Routerに接続できるかを確認
*相互にpingして疎通を確認
*show version と show ip interface brief の interface 数が合っているかの確認
*Routerへの接続を正常に切断できるかの確認
#試験ネットワークのトポロジー
以下のように非常に単純なトポロジーです。

```Topology:

    +-------------+        GigaEth2 <-> GigaEth2         +-------------+
    |             | ------------------------------------ |             |
    |    ios1     | 10.10.10.1                10.10.10.2 |    ios2     |
    |             |                                      |             |
    +-------------+                                      +-------------+
```

#サンプルコードの実行方法
二つの方法があります。
スタンドアロンと呼ばれる方法は、テストスクリプト単体を走らせます。

```
python pyats_ios_example.py --testbed pyats_ios_example.yaml
```

--testbed オプションの後に、機器への接続情報などを記述した、YAML形式の Testbed ファイルを指定します。

また、easypy というツールを使って job として走らせることも可能です。easypy を使うと、実行結果や環境などの情報が、zip 形式のファイルでアーカイブされます。

```
easypy pyats_ios_example_job.py -testbed_file pyats_ios_example.yaml
```

#サンプルコードの解説
いくつかの部分を抜き出して開設します。
## Testbed ファイル
Testbed ファイルには、機器のアクセス情報や Testbed のトポロジー情報などを記述できます。以下は、機器のアクセス情報の例です。サンプルコードに含まれていた pyats_ios_example.yaml を一部環境に合わせて変更しています。

```pyats_ios_example.yaml
devices:
  ios1:
    connections:
      defaults:
        class: 'unicon.Unicon'
      a:
        protocol: ssh
        ip: 192.168.122.11
        port: 22
    passwords:
      enable: admin
      line: admin
      tacacs: admin
    tacacs:
      username: admin
    type: ios
```

上記の中で、devices: の後の ios1 というのがデバイス名となります。重要なのは、このデバイス名と、機器のCLIのプロンプト一致することです。つまり、ios1 に ssh でアクセスしたときに、ios1> や　ios1# などのプロンプトが表示されることを期待しています。

また、もう一点重要なのが、class: です。ここに、このデバイスで使用する、Connection Class を記述します。これは、python の以下のステートメントと同義だと思われます。

```
from unicon import Unicon
```
tacacs: username: がログイン時のユーザ名です。認証に TACACS を使用していなくても、ここにユーザ名を指定します。
password: tacacs: も同様で、ログイン時のパスワードをここに指定します。

##コマンドライン引数
以下のように、Testbed ファイルを pyATS 渡すための引数を定義しています。

```python
    import argparse
    from ats.topology import loader

    parser = argparse.ArgumentParser(description = "standalone parser")
    parser.add_argument('--testbed', dest = 'testbed',
                        type = loader.load)
    # parse args
    args, unknown = parser.parse_known_args()

    # and pass all arguments to aetest.main() as kwargs
    aetest.main(**vars(args))
```
--testbed でコマンドラインから渡されたファイルの内容が、loder.load() によってtestbedオブジェクトに変換され、testbed=testbed という Key Word 引数で、pyATS の実行モジュールである aetest に渡されます。

##機器へのアクセス
以下のように、check_topology() の中で、device オブジェクトを、ios1, ios2 という変数に格納しています。YAML ファイルに記述した内容は、スキーマに基づいて適切なオブジェクトに変換されているので、YAML を正しく記述しておけば、テストスクリプト内では、名前を指定するだけで必要な情報にアクセスできます。

```python
class common_setup(aetest.CommonSetup):
    '''Common Setup Section

    Defines subsections that performs configuration common to the entire script.

    '''

    @aetest.subsection
    def check_topology(self,
                       testbed,
                       ios1_name = 'ios1',
                       ios2_name = 'ios2'):
    :
    :
        ios1 = testbed.devices[ios1_name]
        ios2 = testbed.devices[ios2_name]
```

実際のアクセスは、 establish_connections() の中で、device_object.connect() を call して、行っています。

```python
    @aetest.subsection
    def establish_connections(self, steps, ios1, ios2):
        '''
        establish connection to both devices
        '''

        with steps.start('Connecting to Router-1'):
            ios1.connect()

        with steps.start('Connecting to Router-2'):
            ios2.connect()
```

with step.start() はsubsection よりも更に細かい step という単位でテストの実行記録を出力するために使用されています。

##動的なテストケースのループ
pyATS はテストケースの流れを動的に制御する仕組みも持っています。marking_interface_count_testcases() では、実際の機器の数だけ、VerifyInterfaceCountTestcase() を実行します。その際、引数として、機器名を与えることで、各機器に対して、VerifyInterfaceCountTestcase() を一回ずつ実行することになります。

```python
    @aetest.subsection
    def marking_interface_count_testcases(self, testbed):
        '''
        mark the VerifyInterfaceCountTestcase for looping.
        '''

        devices = list(testbed.devices.keys())

        logger.info(banner('Looping VerifyInterfaceCountTestcase'
                           ' for {}'.format(devices)))

        # dynamic loop marking on testcase
        aetest.loop.mark(VerifyInterfaceCountTestcase, device = devices)
```

## Pingテスト
PingTestcase() は単に ping を相互に実行するだけです。aetest.Testcase のクラスは、スクリプトに記述された順に実行されます。
ここでは、device 引数に明示的に、'ios1', 'ios2' という機器名を与えて、@aetest.loop で一つのテストケースをそれぞれの引数に対して順に実行しています。更に、ping()を@aetest.test.loop() でデコレーションするこで、その内側で'10.10.10.1', '10.10.10.2' という引数についてもループをさせています。
結果として、ios1, ios2 それぞれが、 10.10.10.1 と 10.10.10.2 にpingを送ります。

```python
@aetest.loop(device = ('ios1', 'ios2'))
class PingTestcase(aetest.Testcase):
    '''Ping test'''

    groups = ('basic', 'looping')

    @aetest.test.loop(destination = ('10.10.10.1', '10.10.10.2'))
    def ping(self, device, destination):
```

## Interface数の比較
VerifyInterfaceCountTestcase() は show version と show ip interface brief のインターフェースの数の比較を行います。私の環境では、Interfaceの表示が微妙にサンプルコードとは異なるの一部以下のような書き換えました。

シリアルインターフェースは存在しないので、show version に関して、Serial に関する正規表現を削除して、serial interface 数はゼロにしました。また、GigabitEthernetなど、Ethernetの前に修飾子が付いても大丈夫なようにしました。

```python
            # extract interfaces counts from `show version`
            match = re.search(r'(?P<ethernet>\d+) .*Ethernet interfaces\r\n'
                               '(?P<serial>\d+) Serial interfaces\r\n', result)
            ethernet_intf_count = int(match.group('ethernet'))
            serial_intf_count = int(match.group('serial'))
```

```python
            # extract interfaces counts from `show version`
            match = re.search(r'(?P<ethernet>\d+) .*Ethernet interfaces\r\n'
                               , result)
            ethernet_intf_count = int(match.group('ethernet'))
            serial_intf_count = 0
```


同じく、show ip interface brief もGigabitEthernetなど、Ethernetの前に修飾子が付いても大丈夫なようにしました。

```python
            # extract ethernet interfaces
            ethernet_interfaces = re.finditer(r'\r\nEthernet\d+\d+\s+', result)
```

```python
            # extract ethernet interfaces
            ethernet_interfaces = re.finditer(r'\r\n.*Ethernet\d+\s+', result)
```

#実行結果
途中経過では様々なログがでますが、最後に以下のようなテストケースの実行結果がサマリーとして出てきます。どの部分でFailしたかなどを後から調べやすくなっています。

```
2017-12-24T22:28:26: %AETEST-INFO: +------------------------------------------------------------------------------+
2017-12-24T22:28:26: %AETEST-INFO:  SECTIONS/TESTCASES                                                      RESULT
2017-12-24T22:28:26: %AETEST-INFO: --------------------------------------------------------------------------------
2017-12-24T22:28:26: %AETEST-INFO: .
2017-12-24T22:28:26: %AETEST-INFO: |-- common_setup                                                         PASSED
2017-12-24T22:28:26: %AETEST-INFO: |   |-- check_topology                                                   PASSED
2017-12-24T22:28:26: %AETEST-INFO: |   |-- establish_connections                                            PASSED
2017-12-24T22:28:26: %AETEST-INFO: |   |   |-- Step 1: Connecting to Router-1                               PASSED
2017-12-24T22:28:26: %AETEST-INFO: |   |   `-- Step 2: Connecting to Router-2                               PASSED
2017-12-24T22:28:26: %AETEST-INFO: |   `-- marking_interface_count_testcases                                PASSED
2017-12-24T22:28:26: %AETEST-INFO: |-- PingTestcase[device=ios1]                                            PASSED
2017-12-24T22:28:26: %AETEST-INFO: |   |-- ping[destination=10.10.10.1]                                     PASSED
2017-12-24T22:28:26: %AETEST-INFO: |   `-- ping[destination=10.10.10.2]                                     PASSED
2017-12-24T22:28:26: %AETEST-INFO: |-- PingTestcase[device=ios2]                                            PASSED
2017-12-24T22:28:26: %AETEST-INFO: |   |-- ping[destination=10.10.10.1]                                     PASSED
2017-12-24T22:28:26: %AETEST-INFO: |   `-- ping[destination=10.10.10.2]                                     PASSED
2017-12-24T22:28:26: %AETEST-INFO: |-- VerifyInterfaceCountTestcase[device=ios1]                            PASSED
2017-12-24T22:28:26: %AETEST-INFO: |   |-- extract_interface_count                                          PASSED
2017-12-24T22:28:26: %AETEST-INFO: |   `-- verify_interface_count                                           PASSED
2017-12-24T22:28:26: %AETEST-INFO: |-- VerifyInterfaceCountTestcase[device=ios2]                            PASSED
2017-12-24T22:28:26: %AETEST-INFO: |   |-- extract_interface_count                                          PASSED
2017-12-24T22:28:26: %AETEST-INFO: |   `-- verify_interface_count                                           PASSED
2017-12-24T22:28:26: %AETEST-INFO: `-- common_cleanup                                                       PASSED
2017-12-24T22:28:26: %AETEST-INFO:     `-- disconnect                                                       PASSED
2017-12-24T22:28:26: %AETEST-INFO:         |-- Step 1: Disconnecting from Router-1                          PASSED
2017-12-24T22:28:26: %AETEST-INFO:         `-- Step 2: Disconnecting from Router-2                          PASSED
2017-12-24T22:28:26: %AETEST-INFO: +------------------------------------------------------------------------------+
2017-12-24T22:28:26: %AETEST-INFO: |                                   Summary                                    |
2017-12-24T22:28:26: %AETEST-INFO: +------------------------------------------------------------------------------+
2017-12-24T22:28:26: %AETEST-INFO:  Number of ABORTED                                                            0
2017-12-24T22:28:26: %AETEST-INFO:  Number of BLOCKED                                                            0
2017-12-24T22:28:26: %AETEST-INFO:  Number of ERRORED                                                            0
2017-12-24T22:28:26: %AETEST-INFO:  Number of FAILED                                                             0
2017-12-24T22:28:26: %AETEST-INFO:  Number of PASSED                                                             6
2017-12-24T22:28:26: %AETEST-INFO:  Number of PASSX                                                              0
2017-12-24T22:28:26: %AETEST-INFO:  Number of SKIPPED                                                            0
2017-12-24T22:28:26: %AETEST-INFO: --------------------------------------------------------------------------------
```