Ansibleを使ってCMLでラボ構築


## はじめに
この記事はシスコの有志による Cisco Systems Japan Advent Calendar 2020 (2枚目) の 2 日目として投稿しています。

- 2017年版: https://qiita.com/advent-calendar/2017/cisco
- 2018年版: https://qiita.com/advent-calendar/2018/cisco
- 2019年版: https://qiita.com/advent-calendar/2019/cisco
- 2020年版: https://qiita.com/advent-calendar/2020/cisco
- 2020年版(2枚目): https://qiita.com/advent-calendar/2020/cisco2 (これ)

この記事では、CML(Cisco Modeling Labs)上のエミューレータ機器に対して、Ansibleを利用して設定を自動化する手順について紹介します。

## CML(Cisco Modeling Labs)とは
CMLは以前はVIRLとよばれていたCisco製品用のネットワークシミュレータ製品の後継製品です。企業向けは元々CMLと呼ばれていて、個人向けのほうの名前がVIRLだったのですが、CML Version 2になって同じ名前で統一されました。VIRLと同じ機能はCMLのPersonal, Personal+というライセンスで購入することが可能です。

CMLはVersion2になって内部アーキテクチャとUIが一新され、かなり使いやすい製品になっています。

[Cisco Modeling Labs:network simulator, network simulation, network modeling, networking simulator, networking simulation - Cisco DevNet](https://developer.cisco.com/modeling-labs/)

CMLの具体的な利用方法に関しては以下が参考になると思うので興味がある人はどうぞ。
(資料を見るにはCisco LearningNetworkへの登録が必要です)

[Cisco Modeling Labs (CML)を使ってネットワークを学ぼう！（基礎編）](https://learningnetwork.cisco.com/s/article/jp-webinar-cml01)

## Ansible Modules for CML
CMLではREST APIがサポートされていて、そのREST APIを使ったPython SDK(virl2-client)が以下のリポジトリにあります。
このライブラリを使うことでCML2上でラボを作ったりVMを起動する操作をプログラミングすることが可能です。

[GitHub - CiscoDevNet/virl2-client: Client library for the Cisco VIRL 2 Network Simulation Platform](https://github.com/ciscodevnet/virl2-client)

ドキュメントはこちら。

[VIRL 2 API Client Documentation - VIRL2 Client - Document - Cisco DevNet](https://developer.cisco.com/docs/virl2-client/)

更に、virl2-clientを内部で使ったAnsible Module(ansible-virl)が公開されています。今回はこのAnsible Moduleを使ってCML2上で簡単なラボを作ってみます。

[GitHub - CiscoDevNet/ansible-virl](https://github.com/CiscoDevNet/ansible-virl)

## ansible-virlのインストール
Mac(Catalina)上で検証していますが、普通にLinuxでも動作するんじゃないかと思います。WindowsはAnsibleがサポートしていないのでWSLなどを使ってもらえればと思います。

利用するPython環境に、ansible, ansible-virlをインストールします。
(virl2-clientはansible-virlをインストールすると自動で追加されます。)

netaddrはこの後Playbookを書く際にipaddrフィルタを使っているのであわせてインストールしていますが、ansible-virlには必須ではないです。

```sh
pip install ansible ansible-virl netaddr
```

これで準備は完了です。

## Ansible Modules
ansible-virlには以下の3つのAnsible Moduleと、Inventory Pluginが含まれています。

モジュール

- virl_lab ... CML上のラボを管理するモジュール
- virl_node ... ラボ内のノードを管理するモジュール
- virl_lab_facts ... ラボのノードに関する情報を取得するモジュール
- virl_interface ... リポジトリにはあるが未実装

### virl_lab
新しくラボを作成するにはvirl_labを利用します。以下のようなTaskになります。

```yaml
- name: Create the lab
  virl_lab:
    host: "{{ virl_host }}"
    user: "{{ virl_username }}"
    password: "{{ virl_password }}"
    lab: "{{ virl_lab }}"
    state: present
    file: "{{ virl_lab_file }}"
  register: results
```

`host`, `user`, `password`はそれぞれCMLのホストサーバ、ユーザ名、パスワードです。
パラメータとして設定しない場合は、環境変数(VIRL_USERNAME, VIRL_PASSWORD, VIRL_HOST)を参照します。

`lab`はCMLのラボ名です。なければ作ります。このパラメータも設定しない場合は環境変数 VIRL_LABを参照します。

`file`はCMLラボの構成を定義したYAMLファイルを設定できます。何も設定しないと空のラボを作成します。
CMLでラボを作成していてそれを雛形にしたい場合は、CMLのWorkbenchでのメニュー(Download Lab)からYAMLファイルをダウンロードして設定可能です。

### virl_node
ラボにノードを追加・削除したり、ノードを起動・停止することができるモジュールです。

```yaml
- name: Start Node
  virl_node:
    name: "{{ inventory_hostname }}"
    host: "{{ virl_host }}"
    user: "{{ virl_username }}"
    password: "{{ virl_password }}"
    lab: "{{ virl_lab }}"
    state: started   # present, absent, started, stoped, wiped
    image_definition: "{{ virl_image_definition | default(omit) }}"
    node_definition: "{{ virl_node_definition | default(omit) }}"
    config: "{{ day0_config | default(omit) }}"
```

`node_definition`はノードの種類(IOSv, NM-OSvなど)を設定するためのパラメータ(Node ID)です。
`image_definition`はノードのイメージ(iosv-15.3.8など)を設定するためのパラメータ(Image ID)です。
`node_definition`, `image_definition`はノードを作成するときに必要なパラメータになります。

各IDはCMLのLab Managerから、Tools > Node and Image Definitionから確認できます。

`config`は、ノードに設定するコンフィグを設定できます。stateが、startedのみのときに有効なようです。

このAnsible Moduleはいまいち実装が足りなくて、例えば作成したノードのトポロジのX,Y座標を設定したり、インタフェースを設定したりすることができないようです。リポジトリ上には`virl_interface`というモジュールもあり、ノード間のインタフェースの設定ができそうな感じですが、残念ながらまだ実装されていないようです。

このため、`virl_node`は現状ノードを作成するには現状では十分ではないので、ノードの起動・停止や設定をいれるのにとどめて、ノードの作成は`virl_lab`の`file`パラメータを使って作成した方が良さそうです。

### virl_lab_facts
CMLラボに含まれるノードの情報が返ってきます。

```yaml
- name: Collect Facts
  virl_lab_facts:
    host: "{{ virl_host }}"
    user: "{{ virl_username }}"
    password: "{{ virl_password }}"
    lab: "{{ virl_lab }}"
  register: result
```

以下のような結果が返ってきます。

```
ok: [localhost] => {
    "result": {
        "changed": false,
        "failed": false,
        "virl_facts": {
            "details": {
                "created": "2020-11-30 03:26:46",
                "id": "1651bc",
                "lab_description": "",
                "lab_title": "lab-test",
                "link_count": 9,
                "node_count": 8,
                "state": "STARTED"
            },
            "nodes": {
                "desktop-0": {
                    "ansible_host": null,
                    "config": "# this is a shell script which will be sourced at boot\n# if you change the hostname then you need to add a\n# /etc/hosts entry as well to make X11 happy\n# hostname inserthostname_here\n# like this:\n# echo \"127.0.0.1   inserthostname_here\" >>/etc/hosts",
                    "cpus": null,
                    "data_volume": null,
                    "image_definition": "desktop",
                    "interfaces": {
                        "eth0": {
                            "ipv4_addresses": [],
                            "ipv6_addresses": [],
                            "mac_address": "52:54:00:15:34:6b",
                            "state": "STARTED"
                        }
                    },
                    "node_definition": "desktop",
                    "ram": null,
                    "state": "BOOTED",
                    "tags": []
                },
...
```

### Inventory Plugin
CML向けのInventory Pluginが含まれています。virl.yaml(or virl.yml)という名前のインベントリファイルを作ってAnsibleを実行するとCML内のノードにたいしてインベントリとして利用可能です。

```yaml:virl.yaml
plugin: virl
host: cmlhost       # 設定しなければVIRL_HOSTを使う
user: cmluser       # 設定しなければVIRL_USERNAMEを使う
password: password  # 設定しなければVIRL_PASSWORDを使う
lab: labname        # 設定しなければVIRL_LABを使う
```

以下のようなインベントリが作成されます。

```
> ansible-inventory -i virl.yaml --list
SSL Verification disabled
Please ensure the client version is compatible with the server version. client 2.1.0, server 2.0.0
{
    "_meta": {
        "hostvars": {
            "iosv1": {
                "virl_facts": {
                    "config": "interface GigabitEthernet 0/1\n ip address 192.168.16.1 255.255.255.0\n no shut       \n",
                    "cpus": null,
                    "data_volume": null,
                    "image_definition": "iosv-158-3",
                    "interfaces": [
                        {
                            "ipv4_addresses": null,
                            "ipv6_addresses": null,
                            "mac_address": null,
                            "name": "Loopback0",
                            "state": "STARTED"
                        },
...
```

## CML上にラボを作成するPlaybook
実際にPlaybookを作成してみます。今回は以下のようなシンプルなラボを作成します。

```
[IOSv1]--------[Unmanaged Switch]---------[IOSv2]
     Gi0/0   Port0             Port1     Gi0/0
```

- IOSv1はGi0/0に192.168.16.1/24を設定
- IOSv2はGi0/0に192.168.16.2/24を設定

以下のようなPlaybookになります。CMLにしか接続しないので、インベントリは作成していません。
(長くなるので、host, user, passwordは環境変数からとってきている前提です)

```yaml:playbook.yaml
- hosts: localhost
  connection: local
  gather_facts: no
  vars:
    lab: mylab
  tasks:
    - name: Create Lab
      virl_lab:
        lab: '{{ lab }}'
        file: lab_template.yaml
    - name: Create IOSv
      virl_node:
        name: '{{ item.name }}'
        lab: '{{ lab }}'
        state: started
        config: |
          interface GigabitEthernet 0/0
           ip address {{ item.addr }} 255.255.255.0
           no shut       
      loop:
        - name: iosv1
          addr: 192.168.16.1
        - name: iosv2
          addr: 192.168.16.2
    - name: Create Unmanaged Switch
      virl_node:
        name: unmanaged-switch
        lab: '{{ lab }}'
        state: started

```

lab_template.yamlは以下のような感じです。
(事前にCMLで作ってエクスポートしています)

```yaml:lab_template.yaml
lab:
  description: ''
  notes: ''
  timestamp: 1606720359.0204537
  title: lab_template
  version: 0.0.3
nodes:
  - id: n0
    label: iosv1
    node_definition: iosv
    x: 100
    y: -100
    configuration: ''
    image_definition: iosv-158-3
    tags: []
    interfaces:
      - id: i0
        label: Loopback0
        type: loopback
      - id: i1
        slot: 0
        label: GigabitEthernet0/0
        type: physical
  - id: n1
    label: iosv2
    node_definition: iosv
    x: -100
    y: -100
    configuration: ''
    image_definition: iosv-158-3
    tags: []
    interfaces:
      - id: i0
        label: Loopback0
        type: loopback
      - id: i1
        slot: 0
        label: GigabitEthernet0/0
        type: physical
  - id: n2
    label: unmanaged-switch
    node_definition: unmanaged_switch
    x: 0
    y: 0
    configuration: ''
    tags: []
    interfaces:
      - id: i0
        slot: 0
        label: port0
        type: physical
      - id: i1
        slot: 1
        label: port1
        type: physical
links:
  - id: l0
    i1: i0
    n1: n2
    i2: i1
    n2: n1
  - id: l1
    i1: i1
    n1: n2
    i2: i1
    n2: n0

```

実行結果です。

```sh
> ansible-playbook playbook.yaml
[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 [Create Lab] ******************************************************************************************************************************
[WARNING]: Module did not set no_log for password
changed: [localhost]

TASK [Create IOSv] *****************************************************************************************************************************
changed: [localhost] => (item={'name': 'iosv1', 'addr': '192.168.16.1'})
changed: [localhost] => (item={'name': 'iosv2', 'addr': '192.168.16.2'})

TASK [Create Unmanaged Switch] *****************************************************************************************************************
changed: [localhost]

PLAY RECAP *************************************************************************************************************************************
localhost                  : ok=3    changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

```

以下のようにCML上にラボが作成されて、すぐに利用できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/23828/daee79f3-f402-3d06-6bf6-0dec1fe580c7.png)


## CML上のノードに対してAnsibleを実行する
上記までは、CMLに対してAnsibleを実行してきましたが、ここからは、CML上のノードに対して直接Ansibleを実行する方法を考えたいと思います。

CMLだけの環境を考えると、`virl_node`で設定を追加できるので大抵の場合は必要ないんですが、実環境へのPlaybookの検証のためにCMLを使っている場合や、状況によって設定を変更したい場合は、`ios_command`, `ios_config`などのAnsible Moduleを直接ノードに対して使えると便利です。

これを実現するためには、Ansibleホストから直接CML上のノードに対してSSHで接続できるようにする必要があります。
考えられる方法がいくつかあります。

1. コンソールサーバー機能を利用して、Ansibelホストからノードのコンソールにアクセス  
   CMLのコンソールサーバー機能を利用してAnsibleのホストからノード上のコンソールにアクセスします。設定が面倒ではないですが、本来のSSHではなくコンソール経由のアクセスになるため、利用できるAnsible Moduleが限定されてしまいます
2. ブレイクアウトツールを利用して、Ansibelホストからノードのコンソールにアクセス  
   ブレイクアウトツールをAnsibleホストにインストールしてCML内のノードにコンソール接続します。これも設定は面倒ではないですが、本来のSSHではなくコンソール経由のアクセスになるため、利用できるAnsible Moduleが限定されてしまいます
3. CML内にAnsibleホストとなるマシンを立てる  
   CML内にLinuxサーバを立ててAnsibleホストとして利用します。直感的で良いですが、CMLラボを作成するAnsibleホストと、各ノードに接続するAnsibleホストが別々になってしまいます
4. External Connectorを利用  
   External Connectorを利用してCMLの外側からラボ内のノードに直接接続します。External Connectorの設定の手間や各ノードにSSHの設定をする手間がありますが、CMLの外側にあるAnsibleホストから直接CML内のノードに接続できるので、物理機器に対して設定するのとほぼ同じ手順でAnsibleでの自動化が可能です。

というわけで、ここでは、External Connectorを利用した方法で実行してみます。

External Connectionの設定にはBridge, NAT, Customがあります。CML内の複数のノードにアクセスする場合はNATにしてPort Forwardingとかできればアドレスも節約できてよいのですが、現状ではExternal NodeのNATでは細かい設定ができず、SSH Port Forwarding的な動作を設定できないので、今回はBridgeを利用し、個々のノードに外部とアクセス可能なIPを設定する方法でいきます。

## ノードを直接Ansibleで操作するPlaybook
以下のようなシンプルなラボを構築します。

```
[External  Connector]--------[IOSv1]
                  Port0    Gi0/1 Loop1000                     
```

以下はAnsible接続用に、`virl_node`で設定

- IOSv1で、Gi0/1にIPアドレス(外部接続用のIPアドレス)と、SSHを有効にする設定諸々

以下は、ノードに直接Ansibleで接続して、`ios_config`で設定

- Loopback1000 Interfaceを作成して、192.168.16.1/24を設定

以下のようなPlaybookになります。今回はインベントリ変数として、外部IPやLoopback IFのIPを設定したかったので、インベントリファイルも作成しています。Inventory Plugin(plugin: virl)によるインベントリファイル(virl.yaml)ですが、個別にインベントリ変数を設定できないようだったので、今回は普通のインベントリファイルにしました。

また、`virl_lab`, `virl_node`に設定するhost, user, password, labは全て環境変数から取得する設定にしています。

```yaml:playbook.yaml
- hosts: localhost
  connection: local
  gather_facts: no
  tasks:
    - name: Create Lab
      virl_lab:
        file: template_ec.yaml
    - name: Start External Connection
      virl_node:
        name: ext-conn
        state: started
- hosts: ios
  connection: local
  gather_facts: no
  tasks:
    - name: Start IOSv
      virl_node:
        name: '{{ inventory_hostname }}'
        state: started
        config: |
          interface GigabitEthernet 0/0
            ip address {{ ansible_host }} 255.255.255.0
            no shut
          ip route 0.0.0.0 0.0.0.0 {{ default_gateway }}
          hostname {{ inventory_hostname }}
          ip domain-name cml.lab
          crypto key generate rsa modulus 2048
          ip ssh version 2
          line vty 0 4 
            transport input ssh
            login local
          username {{ ansible_user }} password {{ ansible_password }}
          enable password {{ ansible_become_pass }}
- hosts: ios
  connection: network_cli
  gather_facts: no
  tasks:
    - name: Wait for connection
      wait_for_connection:
    - name: Config Loop1000
      ios_config:
        lines: ip address {{ loopif_ip|ipaddr('address') }} {{ loopif_ip|ipaddr('netmask') }}
        parents: interface Loopback1000
      become: yes
```

```ini:inventory
[ios]
iosv1 ansible_host=10.1.1.1 default_gateway=10.1.1.254 loopif_ip=192.168.16.1/24

[ios:vars]
ansible_network_os=ios
ansible_user=cisco
ansible_password=cisco
ansible_become_pass=cisco
```

```yaml:template_ec.yaml
lab:
  description: ''
  notes: ''
  timestamp: 1606726982.048025
  title: template
  version: 0.0.3
nodes:
  - id: n0
    label: ext-conn
    node_definition: external_connector
    x: -500
    y: 0
    configuration: bridge0
    tags: []
    interfaces:
      - id: i0
        slot: 0
        label: port
        type: physical
  - id: n1
    label: iosv1
    node_definition: iosv
    x: -350
    y: 0
    configuration: ''
    image_definition: iosv-158-3
    tags: []
    interfaces:
      - id: i0
        label: Loopback0
        type: loopback
      - id: i1
        slot: 0
        label: GigabitEthernet0/0
        type: physical
      - id: i2
        slot: 1
        label: GigabitEthernet0/1
        type: physical
      - id: i3
        slot: 2
        label: GigabitEthernet0/2
        type: physical
      - id: i4
        slot: 3
        label: GigabitEthernet0/3
        type: physical
links:
  - id: l0
    i1: i0
    n1: n0
    i2: i1
    n2: n1

```

実行結果です。

```
❯ ansible-playbook -i inventory playbook.yaml

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

TASK [Create Lab] ******************************************************************************************************************************
[WARNING]: Module did not set no_log for password
changed: [localhost]

TASK [Start External Connection] ***************************************************************************************************************
changed: [localhost]

PLAY [ios] *************************************************************************************************************************************

TASK [Start IOSv] ******************************************************************************************************************************
changed: [iosv1]

PLAY [ios] *************************************************************************************************************************************

TASK [Wait for connection] *********************************************************************************************************************
ok: [iosv1]

TASK [Config Loop1000] *************************************************************************************************************************
changed: [iosv1]

PLAY RECAP *************************************************************************************************************************************
iosv1                      : ok=3    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
localhost                  : ok=2    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

```

以下のようなラボがCMLで作成されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/23828/42550463-770d-31eb-3b00-4e6f8da870e6.png)


## まとめ
virl-ansibleですが、実装が不十分なところがありますが、使い方を工夫すれば十分に利用できそうです。また、CMLが実装しているREST APIやPython SDK(virl2-client)では必要な機能は実装されているようなので、首を長くして待っていればそのうち実装を追加してくれるかもしれません。

実装してくれなくてもある程度PythonやAnsibleのモジュール作成の知識があれば自分でモジュールを作ることも可能そうです。

CML自体は検証環境を作ったり勉強用の環境を作るのに非常に便利なので、CMLでの環境構築もAnsibleで自動化することで、更なるハッピーネットワークエンジニアライフがおくれることでしょう???