Ansibleのnetwork_cliコネクションプラグインをカスタマイズしてみた

# はじめに

以前の投稿で、ターミナルサーバ経由でNW機器を操作するためのモジュール自作例をご紹介しました。
[Ansible自作モジュールを使ってターミナルサーバ経由でNW機器を操作してみた](https://qiita.com/tech_kitara/items/777e90e1313f1ea426ee)

自作した`console_command`モジュールは、コマンド実行操作を`netmiko`というPythonライブラリで実装していました。
今回はモジュールではなく、NW機器へCLIログインするための`network_cli`コネクションプラグインの方を、コンソール接続に対応できるようカスタマイズし、公式の`ios_command`モジュール等がそのまま使えるようにしてみました。


# セットアップ

- **NW構成**  
ターミナルサーバとルータ×2台を準備し、以下の通り接続しました。
![無題14-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/310082/a32b128b-d2db-b2e3-160a-2ab11235fe1b.png)  
- **ターミナルサーバ、ルータ**
Ansibleから各ルータへSSHログインする際は、ターミナルサーバのIPアドレス宛に、TCPポート番号3001と3002を使って接続します。
またログイン時、ターミナルサーバに設定したユーザ名/パスワード、ルータに設定したenableパスワードが聞かれる設定にしています。
  
- **PC(CentOS)**
Ansibleのインストールに加え、以下を実施しています。
- netmikoのインストール

```  
$ sudo pip install netmiko 
```
 
   - hostsファイルの追記  
今回2台のルータのIPアドレスは同じなのですが、Inventoryファイルに同じIPアドレスを記載すると、その内1台にしか操作が加えられないため、便宜的に以下の通りhostsファイルの追記を行い、1つのIPアドレスに2つのホスト名が割り当てられるようにしています。
  
```
$ sudo vi /etc/hosts

192.168.100.79 con_port1
192.168.100.79 con_port2
```

# Inventoryファイル
以下の通り作成しました。ポート番号を指定している点、ターミナルサーバのユーザ名/パスワードを使用している点以外は、通常のSSH接続のInventoryファイルと同様です。

```:inventory_con
[cisco]
con_port1 ansible_port=3001
con_port2 ansible_port=3002

[cisco:vars]
ansible_network_os=ios
ansible_user=(ターミナルサーバのユーザ名)
ansible_password=(ターミナルサーバのパスワード)
ansible_become=yes
ansible_become_method=enable
ansible_become_pass=(ルータのenableパスワード)
```

** 追記 **
以下の書き方であれば、hostsファイルの追記は不要でした。

```:inventory_con
[cisco]
hqborder2 ansible_host=192.168.100.79 ansible_port=3001
brborder2 ansible_host=192.168.100.79 ansible_port=3002

[cisco:vars]
ansible_network_os=ios
ansible_user=(ターミナルサーバのユーザ名)
ansible_password=(ターミナルサーバのパスワード)
ansible_become=yes
ansible_become_method=enable
ansible_become_pass=(ルータのenableパスワード)
```

# Playbook
Cisco IOS用の`ios_config`モジュールを使用し、(1)Configに書かれたホスト名情報、(2)ログインユーザ情報を取得し、`debug`モジュールで結果を標準出力しています。

```playbook1_con.yml
---

- hosts: cisco
  gather_facts: no
  connection: network_cli

  tasks:
    - name: run show command
      ios_command:
        commands:
          - show run | include hostname   #(1)
          - show users   #(2)
      register: result

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

# network_cliコネクションプラグイン
### network_cli.pyの格納場所
`ansible --version`コマンドでansible python module locationを確認し、その配下にあるnetwork_cli.pyを編集しました。元の状態に戻せるよう、現ファイルは別名でバックアップを取っておいたほうが良いと思います。

```
$ sudo vi [ansible python module location]/plugins/connection/network_cli.py
```
ちなみに、Actionプラグイン側で、各モジュールがサポートするコネクションプラグイン(network_cli、httpapi、local等)を指定しているため、例えばnetwork_cli_con.pyのような別ファイルを作成し、Playbook内でプラグイン名をnetwork_cli_conに変えても、「無効なコネクションタイプです」というエラーが出てうまくいきませんでした。

### カスタマイズ内容
以下3点です。

- **パッケージの追加** 
冒頭でnetmikoとansible.module_utils.basicを追加しています。

- **コンソールログインヘルパー1の追加** 
コンソールログイン開始時、Enterキーの押下(改行コードの送信)を実行しないとプロンプトが現れなかったため、Ansibleが`paramiko`モジュールでログイン処理を開始する前に、`netmiko`で改行コードを2回送信する処理を加えました。（以前の投稿で作成したコードを流用。）
なお、通常のログイン時に影響を及ぼさないよう、TCPポート番号が3001～3048の場合のみ本処理を実行させています。

- **コンソールログインヘルパー2の追加** 
`paramiko`によるターミナルサーバのユーザ名/パスワード入力後、ユーザーモードから特権モードに移る間でも改行コードの送信が必要だったため、`paramiko`の`ssh_shell.send(b'\r')`で2回改行コードを送信しています。

```network_cli.py
# (1) import two additional packages
from ansible.module_utils.basic import *
from netmiko import ConnectHandler
# (1) -END-
（中略）
    def _connect(self):
        '''
        Connects to the remote device and starts the terminal
        '''
        # (2) Console login helper1 -START-
        if self._play_context.port is not None and 3001 <= self._play_context.port <= 3048:

            device_args = {
                'device_type' : 'terminal_server',
                'ip' : self._play_context.remote_addr,
                'port' : self._play_context.port,
                'username' : self._play_context.remote_user,
                'password' : self._play_context.password,
            }

            net_connect = ConnectHandler(**device_args)
            net_connect.write_channel('\r')
            time.sleep(1)
            net_connect.write_channel('\r')
            time.sleep(1)
            output = net_connect.read_channel()
            # Disable initial configuration
            if '[yes/no]' in output:
                net_connect.write_channel('no\r')
            # (2) -END-

        if not self.connected:
            self.paramiko_conn = connection_loader.get('paramiko', self._play_context, '/dev/null')
            self.paramiko_conn._set_log_channel(self._get_log_channel())
            self.paramiko_conn.set_options(direct={'look_for_keys': not bool(self._play_context.password and not self._play_context.private_key_file)})
            self.paramiko_conn.force_persistence = self.force_persistence
            ssh = self.paramiko_conn._connect()

            host = self.get_option('host')
            self.queue_message('vvvv', 'ssh connection done, setting terminal')

            self._ssh_shell = ssh.ssh.invoke_shell()
            self._ssh_shell.settimeout(self.get_option('persistent_command_timeout'))

            self._terminal = terminal_loader.get(self._network_os, self)
            if not self._terminal:
                raise AnsibleConnectionFailure('network os %s is not supported' % self._network_os)

            self.queue_message('vvvv', 'loaded terminal plugin for network_os %s' % self._network_os)

            # (3) Console login helper2 -START-
            if self._play_context.port is not None and 3001 <= self._play_context.port <= 3048:
                self._ssh_shell.send(b'\r')
                time.sleep(1)
                self._ssh_shell.send(b'\r')
                time.sleep(1)
                output2 = self._ssh_shell.recv(1000).decode('utf-8')
                if '[yes/no]' in output2:
                    self._ssh_shell.send(b'no')
                self._ssh_shell.send(b'\r')
                # (3) -END-

            self.receive(prompts=self._terminal.terminal_initial_prompt, answer=self._terminal.terminal_initial_answer,
                         newline=self._terminal.terminal_inital_prompt_newline)

            self.queue_message('vvvv', 'firing event: on_open_shell()')
            self._terminal.on_open_shell()

            if self._play_context.become and self._play_context.become_method == 'enable':
                self.queue_message('vvvv', 'firing event: on_become')
                auth_pass = self._play_context.become_pass
                self._terminal.on_become(passwd=auth_pass)

            self.queue_message('vvvv', 'ssh connection has completed successfully')
            self._connected = True

        return self
（中略）
```

# 実行結果
表示されたホスト名から、2台に機器に対してログインできていること、show usersの結果から、想定通りコンソール経由(con 0)でアクセスできていることが分かります。

```yaml:
$ ansible-playbook -i inventory_con playbook1_con.yml

PLAY [cisco] **********************************************************************************

TASK [run show command] ***********************************************************************
ok: [con_port1]
ok: [con_port2]

TASK [debug] **********************************************************************************
ok: [con_port1] => {
    "msg": [
        [
            "hostname hqborder2"
        ],
        [
            "Line       User       Host(s)              Idle       Location",
            "*  0 con 0                idle                 00:00:00   ",
            "",
            "  Interface    User               Mode         Idle     Peer Address"
        ]
    ]
}
ok: [con_port2] => {
    "msg": [
        [
            "hostname brborder2"
        ],
        [
            "Line       User       Host(s)              Idle       Location",
            "*  0 con 0                idle                 00:00:00   ",
            "",
            "  Interface    User               Mode         Idle     Peer Address"
        ]
    ]
}

PLAY RECAP ************************************************************************************
con_port1                  : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
con_port2                  : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
```

# 最後に
ちょっと無理矢理ですが、一応動くものが作れました。ただ、ターミナルサーバの仕様や、NW機器のログイン設定によっては、動作しなかったり想定外の動作をしたりする可能性がありますので、自己責任でお願いします！
これでキッティングが楽になるといいなー。 Happy Automation！