AnsibleでJSON形式のデータを差分比較する

# はじめに
[以前の記事](https://qiita.com/tech_kitara/items/0e8131afc1fd107ae898)で、Cisco IOS-XEのACL操作をRESTCONFで行いました。
RESTCONFでは、HTTP(S)のメッセージボディ部分をXMLやJSON形式で定義できます。
今回は、JSON形式で定義したACL設定と、同じく設定後にJSON形式で取得したACL設定を差分比較し、想定通り設定が反映されているか確認してみたいと思います。

# 1. 用意した環境
Cisco DevNet Sandboxの[IOS XE on CSR Recommended Code](https://devnetsandbox.cisco.com/RM/Diagram/Index/d6ec9e7c-9cf1-488b-8f1d-876cd9f52cb4)のCSR1000v 16.9.3を利用させて頂きました。
Python3.6.8のvenv仮想環境上で、Ansible2.9.2をインストールして使いました。


# 2. フィルタープラグイン
ちょっと探した限り、Ansible公式でJSONデータを一発で差分比較出来そうなものがなかったため、[DeepDiff](https://github.com/seperman/deepdiff)をカスタムフィルタープラグインとして取り込みました。こちらはPythonベースのOSSで、辞書、リスト、文字列等のオブジェクトがネスト構造で組み合わさったデータを再帰的に（深く辿って）差分比較可能です。
最新バージョンではPython 2がサポートされませんので、利用時は注意が必要です。

## 2-1. 事前準備
DeepDiffのインストールを行います。

```
$ pip install deepdiff
```

## 2-2. Pythonコード（抜粋）
[こちらの記事](https://qiita.com/tech_kitara/items/c8879adee80a61379342)と同様、`filter_plugins`ディレクトリ内に以下のファイルを格納しました。（実際には、他のカスタムフィルターも同ファイル内に書いています。）
やっている事は以下の3点です。

- `PY3`と`HAS_DEEPDIFF`で、それぞれPython 3とDeepDiffがインストールされているか判定
- `DeepDiff(output1, output2)`で、2つのJSONデータを差分比較
- 出力結果`diff`を`to_json()`でJSON形式に変換（Ansible側の出力を、インデントありの整形されたデータにするため）

```python:custom_filters1.py
from __future__ import absolute_import, division, print_function
__metaclass__ = type

from ansible.module_utils.six import PY3, string_types
from ansible.errors import AnsibleError, AnsibleFilterError

try:
    from deepdiff import DeepDiff  # For Deep Difference of 2 objects
    HAS_DEEPDIFF = True
except ImportError:
    HAS_DEEPDIFF = False


class FilterModule(object):

    def deepdiff(self, output1, output2):
        if not PY3:
            raise AnsibleFilterError("DeepDiff requires Python 3")
        if not HAS_DEEPDIFF:
            raise AnsibleFilterError("DeefDiff not found. Run 'pip install deepdiff'")
        ddiff = DeepDiff(output1, output2)
        return ddiff.to_json()

    def filters(self):
        return {
            'deepdiff': self.deepdiff
        }
```


# 3. Inventoryファイル
[以前の記事](https://qiita.com/tech_kitara/items/0e8131afc1fd107ae898#2-inventory%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB)と同じものを使いました。


# 4. 差分比較
これ以降で、ACL設定が無い状態から、名前付き拡張ACL`TEST`×2行と、`TEST2`×1行を作成し、事前事後で差分比較してみます。
ただし、一筋縄では行かなかったため、メモを兼ねて、トライ＆エラーの過程を残しておきます。

## 4-1. 実行（1回目）

Playbookの大まかな流れは以下の通りです。

1. 設定するACLをプレイ変数`content_data`で定義。`name: TEST2`以降は、各要素の順番をバラバラに入れ替えています。また、宛先のワイルドカードマスクをあえて誤った設定`dest-mask: 255.255.255.0`にしています（本来は`dest-mask: 0.0.0.255`）。確認のため、`access-list`配下の内容を表示。
2. `restconf_config`モジュールを使い、JSON形式に変換した`content_data`をHTTPSのボディに格納し、 `PATCH`メソッドでマージ。
3. `restconf_get`モジュールを使い、設定されたACL情報をJSON形式で取得。
4. タスク3の出力結果を表示。
5. `deepdiff`フィルタープラグインを用い、タスク1の想定結果とタスク4の出力結果を差分比較。

```yaml:playbook_restconf_create_acl_diff4.yml
---

- hosts: cisco
  gather_facts: no

  vars:
    content_data:
      Cisco-IOS-XE-native:ip:
        access-list:
          Cisco-IOS-XE-acl:extended:
            - access-list-seq-rule:
              - ace-rule:
                  action: permit
                  dest-ipv4-address: 192.168.100.0
                  dest-mask: 0.0.0.255
                  ipv4-address: 192.168.4.0
                  mask: 0.0.0.255
                  protocol: ip
                sequence: 30
              - ace-rule:
                  action: permit
                  dest-ipv4-address: 192.168.100.0
                  dest-mask: 0.0.0.255
                  ipv4-address: 192.168.5.0
                  mask: 0.0.0.255
                  protocol: ip
                sequence: 50
              name: TEST
            - name: TEST2
              access-list-seq-rule:
              - sequence: 70
                ace-rule:
                  action: permit
                  mask: 0.0.0.255
                  dest-ipv4-address: 192.168.100.0
                  dest-mask: 255.255.255.0
                  ipv4-address: 192.168.7.0
                  protocol: ip

  tasks:
    - name: display intended data   # (1)
      debug:
        msg: "{{ content_data['Cisco-IOS-XE-native:ip']['access-list'] }}"

    - name: create new acl   # (2)
      restconf_config:
        path: /data/Cisco-IOS-XE-native:native/ip
        method: patch
        content: "{{ content_data | to_json }}"

    - name: get acl info   # (3)
      restconf_get:
        path: /data/Cisco-IOS-XE-native:native/ip/access-list/extended
      register: result

    - name: display output data   # (4)
      debug:
        msg: "{{ result.response }}"

    - name: diff intended and outputted acl   # (5)
      debug:
        msg: "{{ content_data['Cisco-IOS-XE-native:ip']['access-list'] | deepdiff(result.response) }}"
```

## 4-2. 実行結果（1回目）

- タスク1  
`TEST2`の順番は、標準出力でYAMLからJSON形式に変換される段階で、`TEST`と同じ順番に入れ替わっています。そのため、YAMLで事前定義するデータの順番は意識しなくて良さそうです。
- タスク4  
パッと見た感じ、タスク1と同様の結果が出力されています。  
- タスク5  
いろいろと差分が出てしまいましたorz。 大きくは以下2つです。
  - `"old_type": "AnsibleUnicode"`と`"new_type": "AnsibleUnsafeText"`  
  DeepDiffは、デフォルトでデータのタイプ（型）も見るようです。出力で使うオブジェクトが事前事後で異なるのが原因のようです。
  - `"old_type": "int"`と`"new_type": "AnsibleUnsafeText"`  
  シーケンス番号も、タイプの違いで差分が出ています。

```
$ ansible-playbook -i inventory_restconf1.ini playbook_restconf_create_acl_diff4.yml

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

TASK [display intended data] *******************************************************************************************************************
ok: [csr1000v-1] => {
    "msg": {
        "Cisco-IOS-XE-acl:extended": [
            {
                "access-list-seq-rule": [
                    {
                        "ace-rule": {
                            "action": "permit",
                            "dest-ipv4-address": "192.168.100.0",
                            "dest-mask": "0.0.0.255",
                            "ipv4-address": "192.168.4.0",
                            "mask": "0.0.0.255",
                            "protocol": "ip"
                        },
                        "sequence": 30
                    },
                    {
                        "ace-rule": {
                            "action": "permit",
                            "dest-ipv4-address": "192.168.100.0",
                            "dest-mask": "0.0.0.255",
                            "ipv4-address": "192.168.5.0",
                            "mask": "0.0.0.255",
                            "protocol": "ip"
                        },
                        "sequence": 50
                    }
                ],
                "name": "TEST"
            },
            {
                "access-list-seq-rule": [
                    {
                        "ace-rule": {
                            "action": "permit",
                            "dest-ipv4-address": "192.168.100.0",
                            "dest-mask": "255.255.255.0",
                            "ipv4-address": "192.168.7.0",
                            "mask": "0.0.0.255",
                            "protocol": "ip"
                        },
                        "sequence": 70
                    }
                ],
                "name": "TEST2"
            }
        ]
    }
}

TASK [create new acl] **************************************************************************************************************************
changed: [csr1000v-1]

TASK [get acl info] ****************************************************************************************************************************
ok: [csr1000v-1]

TASK [display output data] *********************************************************************************************************************
ok: [csr1000v-1] => {
    "msg": {
        "Cisco-IOS-XE-acl:extended": [
            {
                "access-list-seq-rule": [
                    {
                        "ace-rule": {
                            "action": "permit",
                            "dest-ipv4-address": "192.168.100.0",
                            "dest-mask": "0.0.0.255",
                            "ipv4-address": "192.168.4.0",
                            "mask": "0.0.0.255",
                            "protocol": "ip"
                        },
                        "sequence": "30"
                    },
                    {
                        "ace-rule": {
                            "action": "permit",
                            "dest-ipv4-address": "192.168.100.0",
                            "dest-mask": "0.0.0.255",
                            "ipv4-address": "192.168.5.0",
                            "mask": "0.0.0.255",
                            "protocol": "ip"
                        },
                        "sequence": "50"
                    }
                ],
                "name": "TEST"
            },
            {
                "access-list-seq-rule": [
                    {
                        "ace-rule": {
                            "action": "permit",
                            "dest-ipv4-address": "192.168.100.0",
                            "dest-mask": "255.255.255.0",
                            "ipv4-address": "192.168.7.0",
                            "mask": "0.0.0.255",
                            "protocol": "ip"
                        },
                        "sequence": "70"
                    }
                ],
                "name": "TEST2"
            }
        ]
    }
}

TASK [diff intended and outputted acl] *********************************************************************************************************
ok: [csr1000v-1] => {
    "msg": {
        "type_changes": {
            "root['Cisco-IOS-XE-acl:extended'][0]['access-list-seq-rule'][0]['ace-rule']['action']": {
                "new_type": "AnsibleUnsafeText",
                "new_value": "permit",
                "old_type": "AnsibleUnicode",
                "old_value": "permit"
            },
            "root['Cisco-IOS-XE-acl:extended'][0]['access-list-seq-rule'][0]['ace-rule']['dest-ipv4-address']": {
                "new_type": "AnsibleUnsafeText",
                "new_value": "192.168.100.0",
                "old_type": "AnsibleUnicode",
                "old_value": "192.168.100.0"
            },
            "root['Cisco-IOS-XE-acl:extended'][0]['access-list-seq-rule'][0]['ace-rule']['dest-mask']": {
                "new_type": "AnsibleUnsafeText",
                "new_value": "0.0.0.255",
                "old_type": "AnsibleUnicode",
                "old_value": "0.0.0.255"
            },
            "root['Cisco-IOS-XE-acl:extended'][0]['access-list-seq-rule'][0]['ace-rule']['ipv4-address']": {
                "new_type": "AnsibleUnsafeText",
                "new_value": "192.168.4.0",
                "old_type": "AnsibleUnicode",
                "old_value": "192.168.4.0"
            },
            "root['Cisco-IOS-XE-acl:extended'][0]['access-list-seq-rule'][0]['ace-rule']['mask']": {
                "new_type": "AnsibleUnsafeText",
                "new_value": "0.0.0.255",
                "old_type": "AnsibleUnicode",
                "old_value": "0.0.0.255"
            },
            "root['Cisco-IOS-XE-acl:extended'][0]['access-list-seq-rule'][0]['ace-rule']['protocol']": {
                "new_type": "AnsibleUnsafeText",
                "new_value": "ip",
                "old_type": "AnsibleUnicode",
                "old_value": "ip"
            },
            "root['Cisco-IOS-XE-acl:extended'][0]['access-list-seq-rule'][0]['sequence']": {
                "new_type": "AnsibleUnsafeText",
                "new_value": "30",
                "old_type": "int",
                "old_value": 30
            },
            "root['Cisco-IOS-XE-acl:extended'][0]['access-list-seq-rule'][1]['ace-rule']['action']": {
                "new_type": "AnsibleUnsafeText",
                "new_value": "permit",
                "old_type": "AnsibleUnicode",
                "old_value": "permit"
            },
            "root['Cisco-IOS-XE-acl:extended'][0]['access-list-seq-rule'][1]['ace-rule']['dest-ipv4-address']": {
                "new_type": "AnsibleUnsafeText",
                "new_value": "192.168.100.0",
                "old_type": "AnsibleUnicode",
                "old_value": "192.168.100.0"
            },
            "root['Cisco-IOS-XE-acl:extended'][0]['access-list-seq-rule'][1]['ace-rule']['dest-mask']": {
                "new_type": "AnsibleUnsafeText",
                "new_value": "0.0.0.255",
                "old_type": "AnsibleUnicode",
                "old_value": "0.0.0.255"
            },
            "root['Cisco-IOS-XE-acl:extended'][0]['access-list-seq-rule'][1]['ace-rule']['ipv4-address']": {
                "new_type": "AnsibleUnsafeText",
                "new_value": "192.168.5.0",
                "old_type": "AnsibleUnicode",
                "old_value": "192.168.5.0"
            },
            "root['Cisco-IOS-XE-acl:extended'][0]['access-list-seq-rule'][1]['ace-rule']['mask']": {
                "new_type": "AnsibleUnsafeText",
                "new_value": "0.0.0.255",
                "old_type": "AnsibleUnicode",
                "old_value": "0.0.0.255"
            },
            "root['Cisco-IOS-XE-acl:extended'][0]['access-list-seq-rule'][1]['ace-rule']['protocol']": {
                "new_type": "AnsibleUnsafeText",
                "new_value": "ip",
                "old_type": "AnsibleUnicode",
                "old_value": "ip"
            },
            "root['Cisco-IOS-XE-acl:extended'][0]['access-list-seq-rule'][1]['sequence']": {
                "new_type": "AnsibleUnsafeText",
                "new_value": "50",
                "old_type": "int",
                "old_value": 50
            },
            "root['Cisco-IOS-XE-acl:extended'][0]['name']": {
                "new_type": "AnsibleUnsafeText",
                "new_value": "TEST",
                "old_type": "AnsibleUnicode",
                "old_value": "TEST"
            },
            "root['Cisco-IOS-XE-acl:extended'][1]['access-list-seq-rule'][0]['ace-rule']['action']": {
                "new_type": "AnsibleUnsafeText",
                "new_value": "permit",
                "old_type": "AnsibleUnicode",
                "old_value": "permit"
            },
            "root['Cisco-IOS-XE-acl:extended'][1]['access-list-seq-rule'][0]['ace-rule']['dest-ipv4-address']": {
                "new_type": "AnsibleUnsafeText",
                "new_value": "192.168.100.0",
                "old_type": "AnsibleUnicode",
                "old_value": "192.168.100.0"
            },
            "root['Cisco-IOS-XE-acl:extended'][1]['access-list-seq-rule'][0]['ace-rule']['dest-mask']": {
                "new_type": "AnsibleUnsafeText",
                "new_value": "255.255.255.0",
                "old_type": "AnsibleUnicode",
                "old_value": "255.255.255.0"
            },
            "root['Cisco-IOS-XE-acl:extended'][1]['access-list-seq-rule'][0]['ace-rule']['ipv4-address']": {
                "new_type": "AnsibleUnsafeText",
                "new_value": "192.168.7.0",
                "old_type": "AnsibleUnicode",
                "old_value": "192.168.7.0"
            },
            "root['Cisco-IOS-XE-acl:extended'][1]['access-list-seq-rule'][0]['ace-rule']['mask']": {
                "new_type": "AnsibleUnsafeText",
                "new_value": "0.0.0.255",
                "old_type": "AnsibleUnicode",
                "old_value": "0.0.0.255"
            },
            "root['Cisco-IOS-XE-acl:extended'][1]['access-list-seq-rule'][0]['ace-rule']['protocol']": {
                "new_type": "AnsibleUnsafeText",
                "new_value": "ip",
                "old_type": "AnsibleUnicode",
                "old_value": "ip"
            },
            "root['Cisco-IOS-XE-acl:extended'][1]['access-list-seq-rule'][0]['sequence']": {
                "new_type": "AnsibleUnsafeText",
                "new_value": "70",
                "old_type": "int",
                "old_value": 70
            },
            "root['Cisco-IOS-XE-acl:extended'][1]['name']": {
                "new_type": "AnsibleUnsafeText",
                "new_value": "TEST2",
                "old_type": "AnsibleUnicode",
                "old_value": "TEST2"
            }
        }
    }
}

PLAY RECAP *************************************************************************************************************************************
csr1000v-1                 : ok=5    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
```

## 4-3. 実行（2回目）
DeepDiffには、比較時のオプションとして`ignore_type_in_groups`があります。これは、事前事後のタイプが特定の組み合わせの場合、「タイプ」の差分比較を無視するものです（データそのものは差分比較する。）

今回は、フィルタープラグインに直接オプションを追加しました。具体的には以下の通り、差分が出てしまった2つのタイプの組み合わせを指定しました。加えて、冒頭で関連するオブジェクトもインポートしました。（うーん、なんか微妙な気が。。）

```python:custom_filters1.py
# ～省略～
try:
    from deepdiff import DeepDiff  # For Deep Difference of 2 objects
    from ansible.parsing.yaml.objects import AnsibleUnicode   # 追加
    from ansible.utils.unsafe_proxy import AnsibleUnsafeText   # 追加
    HAS_DEEPDIFF = True
except ImportError:
    HAS_DEEPDIFF = False
# ～省略～
    def deepdiff(self, output1, output2):
        # ～省略～
        ddiff = DeepDiff(output1, output2, ignore_type_in_groups=[(AnsibleUnicode, AnsibleUnsafeText),(AnsibleUnsafeText, int)])   # 修正
        # ～省略～
```

## 4-4. 実行結果（2回目）
1回目で設定したACLを一旦削除し、同じPlaybookを実行した結果です。
タスク1～4は同じ結果なので割愛します。タスク5を見ると、まだ差分があります！
タイプの違いは見なくなりましたが、シーケンス番号の値そのものが違うと怒られましたorz

```
$ ansible-playbook -i inventory_restconf1.ini playbook_restconf_create_acl_diff4.yml

PLAY [cisco] ***********************************************************************************************************************************
# ～省略～

TASK [diff intended and outputted acl] *********************************************************************************************************
ok: [csr1000v-1] => {
    "msg": {
        "values_changed": {
            "root['Cisco-IOS-XE-acl:extended'][0]['access-list-seq-rule'][0]['sequence']": {
                "new_value": "30",
                "old_value": 30
            },
            "root['Cisco-IOS-XE-acl:extended'][0]['access-list-seq-rule'][1]['sequence']": {
                "new_value": "50",
                "old_value": 50
            },
            "root['Cisco-IOS-XE-acl:extended'][1]['access-list-seq-rule'][0]['sequence']": {
                "new_value": "70",
                "old_value": 70
            }
        }
    }
}

PLAY RECAP *************************************************************************************************************************************
csr1000v-1                 : ok=5    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
```

## 4-5. 実行（3回目）

プレイ変数内の`sequence`がint型にならないよう、`sequence: xx`から`sequence: "xx"`に修正しました。

```yaml:playbook_restconf_create_acl_diff4.yml
  vars:
    content_data:
      Cisco-IOS-XE-native:ip:
        access-list:
          Cisco-IOS-XE-acl:extended:
            - access-list-seq-rule:
              - ace-rule:
                  action: permit
                  dest-ipv4-address: 192.168.100.0
                  dest-mask: 0.0.0.255
                  ipv4-address: 192.168.4.0
                  mask: 0.0.0.255
                  protocol: ip
                sequence: "30"
              - ace-rule:
                  action: permit
                  dest-ipv4-address: 192.168.100.0
                  dest-mask: 0.0.0.255
                  ipv4-address: 192.168.5.0
                  mask: 0.0.0.255
                  protocol: ip
                sequence: "50"
              name: TEST
            - name: TEST2
              access-list-seq-rule:
              - sequence: "70"
                ace-rule:
                  action: permit
                  mask: 0.0.0.255
                  dest-ipv4-address: 192.168.100.0
                  dest-mask: 255.255.255.0
                  ipv4-address: 192.168.7.0
                  protocol: ip
```

## 4-6. 実行結果（3回目）
タスク5の差分がなくなりました！！

```
$ ansible-playbook -i inventory_restconf1.ini playbook_restconf_create_acl_diff4.yml

PLAY [cisco] ***********************************************************************************************************************************
# ～省略～

TASK [diff intended and outputted acl] *********************************************************************************************************
ok: [csr1000v-1] => {
    "msg": {}
}

PLAY RECAP *************************************************************************************************************************************
csr1000v-1                 : ok=5    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0 
```

## 4-7. 残りの差分について

そういえば、ワイルドカードマスクの誤りで差分が出るのを期待していましたが、出ませんでしたね。
> 設定するACLをプレイ変数`content_data`で定義。`name: TEST2`以降は、各要素の順番をバラバラに入れ替えています。また、宛先のワイルドカードマスクをあえて誤った設定`dest-mask: 255.255.255.0`にしています（本来は`dest-mask: 0.0.0.255`）。

このパターンは、本来はアドレスが"0.0.0.0"に書き換わってしまいますが、JSONデータを見ると、"192.168.100.0"のままです。


```json:TEST2のJSON出力結果
            {
                "access-list-seq-rule": [
                    {
                        "ace-rule": {
                            "action": "permit",
                            "dest-ipv4-address": "192.168.100.0",   # ココ
                            "dest-mask": "255.255.255.0",
                            "ipv4-address": "192.168.7.0",
                            "mask": "0.0.0.255",
                            "protocol": "ip"
                        },
                        "sequence": "70"
                    }
                ],
                "name": "TEST2"
            }
```

ここで、SSHログインしてコンフィグを見てみます。

```
csr1000v-1#sh run | begin TEST
ip access-list extended TEST
 permit ip 192.168.4.0 0.0.0.255 192.168.100.0 0.0.0.255
 permit ip 192.168.5.0 0.0.0.255 192.168.100.0 0.0.0.255
ip access-list extended TEST2
 permit ip 192.168.7.0 0.0.0.255 0.0.0.0 255.255.255.0   # ココ
```

想定通り"0.0.0.0"になっています。:joy:


# 最後に
今回は、日々の作業でよく行う差分比較をJSONに対し行ってみました。専用のAnsibleフィルタープラグインを用意し、データの型に注意することで比較まで出来ました。
ただし、RESTCONFで確認できる設定情報と、実際に動いているコンフィグ情報に差分が生じることが分かったため、差分確認はコンフィグに対して行うべきかと思います。
今後改修されるかもしれませんが、今のところ **信用できるのはコンフィグだけ！！！**:relaxed: