うさラボ

お勉強と備忘録

え!!AnsibleでRouteのdiffを!?できらぁ!

2021Ansible Advent Calendar21日目の記事です

qiita.com

RoutingのDiffを取りたい

ansibleでNW機器の設定変更を行った前後のルートの差分を見たい。こんな時どうしていますか? 取得した結果をシンプルに比較してしまうと、ルートのアップタイムが差分として表示され純粋に増えた/減った箇所が分かりにくい場合があります。

そこで、ansibleとntc-templates/カスタムフィルターなどを利用してルートの差分を取得する方法を紹介します。

まず、使ったものを動かすと下記のように、時間を除いたルートの増減のみを表示させることができます。

TASK [route diff (json)] ************************************************************************************************************
--- before
+++ after
@@ -9,0 +10 @@
+{'PROTOCOL': 'S', 'TYPE': '', 'NETWORK': '172.16.0.0', 'MASK': '24', 'DISTANCE': '1', 'METRIC': '0', 'NEXTHOP_IP': '10.9.0.1', 'NEXTHOP_IF': ''}

changed: [localhost]

※ この記事では、ntc-templateでパースした結果で差分を取得することを前提に考えます。

環境

filesにはshow ip routeの実行結果が格納されています。
templatesにはntc-templateのios_show_ip_route.textfsmios_show_ip_route.textfsmを少しだけカスタムしたものを格納しています。

├── ansible.cfg
├── filter_plugins
│   └──  routediff.py
├── files
│   ├── after_ip_route.txt
│   └── before_ip_route.txt
├── templates
│   ├── ios_show_ip_route.textfsm
│   └── ios_show_ip_route_custom.textfsm
└── route_diff.yml

ansible.cfg

ansible.utils.fact_diffを利用するため、CONTEXTを0に設定します。 カスタムフィルタを使う方法もあるので、filter_pluginsに対象のパスを書きます

[diff]
CONTEXT = 0

[defaults]
filter_plugins = filter_plugins
ansible [core 2.12.0]

Collection        Version
----------------- -------
ansible.netcommon 2.4.0  
ansible.utils     2.4.2  

何も考えずにDiffをした時の悩み

何も考えずにshow ip routeの出力結果が記載されているファイルを読み込んでfact_diffで差分を比較してみます。

- name: route diff (text)
  hosts: localhost
  gather_facts: false

  tasks:
    - name: route diff (text)
      ansible.utils.fact_diff:
        before: "{{ lookup('file', playbook_dir + '/files/before_ip_route.txt') }}"
        after: "{{ lookup('file', playbook_dir + '/files/after_ip_route.txt') }}"

実行結果

PLAY [route diff (text)] ********************************************************************************************************************************************************************************************

TASK [route diff (text)] ********************************************************************************************************************************************************************************************
--- before
+++ after
@@ -18 +18 @@
-O        10.2.0.0/24 [110/2] via 10.9.0.1, 00:00:03, GigabitEthernet0/0
+O        10.2.0.0/24 [110/2] via 10.9.0.1, 00:02:10, GigabitEthernet0/0
@@ -22 +22,3 @@
-L        10.100.0.2/32 is directly connected, GigabitEthernet0/2
\ No newline at end of file
+L        10.100.0.2/32 is directly connected, GigabitEthernet0/2
+      172.16.0.0/24 is subnetted, 1 subnets
+S        172.16.0.0 [1/0] via 10.9.0.1
\ No newline at end of file

changed: [localhost]

ルートは変わっていないのに、ダイナミックルーティングで登録されているルートは時間の部分が差分として表示されしまいます。 今回はルートが少ないですがルートが大量にあると時間の差分が大量に出てしまい、純粋に追加や削除になっているルートが分かりにくいですね。
出力が階層のようになってしまっているルートがある場合はもはや何が起きてるのか謎です

@@ -43,2 +44,2 @@
-           [110/20] via 10.62.4.29, 12:55:19, TenGigabitEthernet1/15
-           [110/20] via 10.62.3.29, 12:55:19, TenGigabitEthernet1/16
+           [110/20] via 10.62.4.29, 12:59:19, TenGigabitEthernet1/15
+           [110/20] via 10.62.3.29, 12:59:19, TenGigabitEthernet1/16

ルートの比較だけを取得する

それでは、ここからルート部分の比較をする方法を検討します。

  1. ntc-templatesをカスタムすればできらぁ!
  2. Jinja2で頑張ればできらぁ!
  3. Custom Filter使えばできらぁ!

1. ntc-templatesをカスタムすればできらぁ!

ntc-templatesでshow ip routeをパースした結果を利用して差分を比較します。 しかし、元のテンプレートだとUPTIMEといったデータも保存されるため、このまま比較してしまっては不要な差分が表示されてしまいます。

そこで、もともと存在していたValue UPTIMEをデータとして保存されにようにしてしまいましょう。 UPTIMEとしてマッチしてる正規表現

Value UPTIME (\d[\w:\.]+)

${UPTIME}をすべて\d[\w:\.]+に直接書き換え(乱暴)

カスタム済みテンプレートios_show_ip_route_custom.textfsm

Value Filldown PROTOCOL (\w)
Value Filldown TYPE (\w{0,2})
Value Required,Filldown NETWORK (\d{1,3}.\d{1,3}.\d{1,3}.\d{1,3})
Value Filldown MASK (\d{1,2})
Value DISTANCE (\d+)
Value METRIC (\d+)
Value NEXTHOP_IP (\d{1,3}.\d{1,3}.\d{1,3}.\d{1,3})
Value NEXTHOP_IF ([A-Z][\w\-\.:/]+)

Start
  ^Gateway.* -> Routes
  # Capture time-stamp if vty line has command time-stamping turned on
  ^Load\s+for\s+
  ^Time\s+source\s+is

Routes
  # For "is (variably )subnetted" line, capture mask, clear all values.
  ^\s+\d{1,3}.\d{1,3}.\d{1,3}.\d{1,3}\/${MASK}\sis -> Clear
  #
  # Match directly connected route with explicit mask
  ^${PROTOCOL}(\s|\*)${TYPE}\s+${NETWORK}\/${MASK}\sis\sdirectly\sconnected,\s${NEXTHOP_IF} -> Record
  #
  # Match directly connected route (mask is inherited from "is subnetted")
  ^${PROTOCOL}(\s|\*)${TYPE}\s+${NETWORK}\sis\sdirectly\sconnected,\s${NEXTHOP_IF} -> Record
  #
  # Match regular routes, with mask, where all data in same line
  ^${PROTOCOL}(\s|\*)${TYPE}\s+${NETWORK}\/${MASK}\s\[${DISTANCE}/${METRIC}\]\svia\s${NEXTHOP_IP}(,\s\d[\w:\.]+)?(,\s${NEXTHOP_IF})? -> Record
  #
  # Match regular route, all one line, where mask is learned from "is subnetted" line
  ^${PROTOCOL}(\s|\*)${TYPE}\s+${NETWORK}\s\[${DISTANCE}\/${METRIC}\]\svia\s${NEXTHOP_IP}(,\s\d[\w:\.]+)?(,\s${NEXTHOP_IF})? -> Record
  #
  # Match route with no via statement (Null via protocol)
  ^${PROTOCOL}(\s|\*)${TYPE}\s+${NETWORK}\/${MASK}\s\[${DISTANCE}/${METRIC}\],\s\d[\w:\.]+,\s${NEXTHOP_IF} -> Record
  #
  # Match "is a summary" routes (often Null0)
  ^${PROTOCOL}(\s|\*)${TYPE}\s+${NETWORK}\/${MASK}\sis\sa\ssummary,\s\d[\w:\.]+,\s${NEXTHOP_IF} -> Record
  #
  # Match regular routes where the network/mask is on the line above the rest of the route
  ^${PROTOCOL}(\s|\*)${TYPE}\s+${NETWORK}\/${MASK} -> Next
  #
  # Match regular routes where the network only (mask from subnetted line) is on the line above the rest of the route
  ^${PROTOCOL}(\s|\*)${TYPE}\s+${NETWORK} -> Next
  #
  # Match the rest of the route information on line below network (and possibly mask)
  ^\s+\[${DISTANCE}\/${METRIC}\]\svia\s${NEXTHOP_IP}(,\s\d[\w:\.]+)?(,\s${NEXTHOP_IF})? -> Record
  #
  # Match load-balanced routes
  ^\s+\[${DISTANCE}\/${METRIC}\]\svia\s${NEXTHOP_IP} -> Record
  #
  # Clear all variables on empty lines
  ^\s* -> Clearall

EOF

これを使ってパースすると、UPTIMEがパース後の結果に含まれなくなります。

(~snip~)
            {
                "DISTANCE": "110",
                "MASK": "24",
                "METRIC": "20",
                "NETWORK": "10.6.234.0",
                "NEXTHOP_IF": "Serial0/0/0:0",
                "NEXTHOP_IP": "10.64.4.90",
                "PROTOCOL": "O",
                "TYPE": "E2"
            },
(~snip~)

つまり、カスタムしたテンプレートを使ってパースした2つのshow ip routeの差分を見ると、ルートの増減のみ確認できます。

- name: Parse2(Change Textfsm)
  hosts: localhost
  gather_facts: false

  tasks:
    - name: parse (before)
      ansible.utils.cli_parse:
        text: "{{ lookup('file', playbook_dir + '/files/before_ip_route.txt') }}"
        parser:
          name: ansible.utils.textfsm
          template_path: "{{ playbook_dir }}/templates/ios_show_ip_route_custom.textfsm"
      register: parser_before_ip_route

    - name: parse (after)
      ansible.utils.cli_parse:
        text: "{{ lookup('file', playbook_dir + '/files/after_ip_route.txt') }}"
        parser:
          name: ansible.utils.textfsm
          template_path: "{{ playbook_dir }}/templates/ios_show_ip_route_custom.textfsm"
      register: parser_after_ip_route

    - name: route diff (json)
      ansible.utils.fact_diff:
        before: "{{ parser_before_ip_route.parsed }}"
        after: "{{ parser_after_ip_route.parsed }}"

実行

PLAY [Parse2(Change Textfsm)] *********************************************************************************************************

TASK [parse (before)] **************************************************************************************************************
ok: [localhost]

TASK [parse (after)] **************************************************************************************************************
ok: [localhost]

TASK [route diff (json)] ************************************************************************************************************
--- before
+++ after
@@ -9,0 +10 @@
+{'PROTOCOL': 'S', 'TYPE': '', 'NETWORK': '172.16.0.0', 'MASK': '24', 'DISTANCE': '1', 'METRIC': '0', 'NEXTHOP_IP': '10.9.0.1', 'NEXTHOP_IF': ''}

changed: [localhost]

これで、ルートの差分が取れました
元々あるテンプレートに手を入れる方法なので、変更点が分かりにくくなる。
これを許してしまうと無限にカスタムテンプレートが出来上がってしまう。といった課題はあるのものの手段として使えそうです。

2. Jinja2で頑張ればできらぁ!

続いて、Jinja2を使って同じことをする方法を紹介します。
Jinja2を使ってパースしてもよいのですが、ntc-templateでパースした構造化データのUPTIMEをjinja2で取り除き差分を取ります。

- name: Parse3(Jinja2)
  hosts: localhost
  gather_facts: false

  tasks:
    - name: parse before_ip_route
      ansible.utils.cli_parse:
        text: "{{ lookup('file', playbook_dir + '/files/before_ip_route.txt') }}"
        parser:
          name: ansible.utils.textfsm
          template_path: "{{ playbook_dir }}/templates/ios_show_ip_route.textfsm"
      register: parser_before_ip_route

    - name: parse after_ip_route
      ansible.utils.cli_parse:
        text: "{{ lookup('file', playbook_dir + '/files/after_ip_route.txt') }}"
        parser:
          name: ansible.utils.textfsm
          template_path: "{{ playbook_dir }}/templates/ios_show_ip.textfsm"
      register: parser_after_ip_route


    - name: set_fact
      set_fact:
        fact_parser_before_ip_route: |
          {% set _ = [] %}
          {% for i in parser_before_ip_route.parsed %}
          {% set __ = i.pop('UPTIME') %}
          {% set _ = _.append(i) %}
          {% endfor %}
          {{ _ }}
        fact_parser_after_ip_route: |
          {% set _ = [] %}
          {% for i in parser_after_ip_route.parsed %}
          {% set __ = i.pop('UPTIME') %}
          {% set _ = _.append(i) %}
          {% endfor %}
          {{ _ }}

    - name: route diff (json)
      ansible.utils.fact_diff:
        before: "{{ fact_parser_before_ip_route_simple }}"
        after: "{{ fact_parser_after_ip_route_simple }}"

ちょっと無茶しやがって。。感がありますが、単純にpopUPTIMEを取り除いた結果を再格納しています。

PLAY [Parse3(Jinja2)] ***********************************************************************************************************************************************************************************************

TASK [parse before_ip_route] *********************************************************************************************************************************************************************************
ok: [localhost]

TASK [parse after_ip_route] **********************************************************************************************************************************************************************************
ok: [localhost]

TASK [set_fact] *****************************************************************************************************************************************************************************************************
ok: [localhost]

TASK [route diff (json)] ************************************************************************************************************
--- before
+++ after
@@ -9,0 +10 @@
+{'PROTOCOL': 'S', 'TYPE': '', 'NETWORK': '172.16.0.0', 'MASK': '24', 'DISTANCE': '1', 'METRIC': '0', 'NEXTHOP_IP': '10.9.0.1', 'NEXTHOP_IF': ''}

changed: [localhost]

Playbookでの工夫で回避できるので、一番簡単な方法かもしれません。
ただJinja2をべた書きされていると、Playbookの可読性が下がってしまいますね。

3. Custom Filter使えばできらぁ!

最後に、カスタムフィルターを使った方法を紹介します。
やっている内容12と同じく、ntc-templateでパースされたデータからUPTIMEを取り除いているだけです。

#!/usr/bin/python

class FilterModule(object):
  def pop_uptime(self, input_value):
    output = list()
    for i in input_value:
      i.pop('UPTIME')
      output.append(i)
    return output

  def filters(self):
      return {
          'pop_uptime': self.pop_uptime,
      }

複雑な処理は裏に隠してしまおうといったイメージですね。カスタムフィルターを使うと柔軟性は跳ね上がります。
例えばsortなどしたい!と思ったときにはカスタムフィルターの機能を追加すれば対応できます。
今回は限定的な処理なので、role化しfilter_pluginsディレクトリにおいて管理するほうがいいかなぁと思います。

pop_uptimeの利用

~(parseは省略)~
- name: route diff (diff)
  ansible.utils.fact_diff:
    before: "{{ parser_before_ip_route.parsed \
                             | pop_uptime() }}"
    after: "{{ parser_after_ip_route.parsed \
                             | pop_uptime() }}"

利用方法がとてもシンプルになります。 カスタムフィルターを作成/メンテする必要があり、Pythonを書けないと作れないのが課題ですかね。

ここがむずいよ

最初はテキストから単純に時間の部分をreplaceすればいいかな?と考えていたんですが、思ったよりも面倒でした。

時間の表記方法が多彩

多分基本形

O       172.20.1.1 [110/11] via 194.0.0.2, 00:05:45, FastEthernet0/0.100

日付(d)と時間(h)パターン

O E2    4.4.0.0 [110/20] via 194.0.0.2, 1d18h, FastEthernet0/0.100

え!?改行!?

B       6.6.0.0 [200/0] via 195.0.0.1, 00:00:04
     172.16.0.0/26 is subnetted, 1 subnets

すべてのパターンに対応するように正規表現を書いてreplaceすれば、時間部分を消した情報で比較もできると思いましたが、大変そうだったのでやめました。

まとめ

多少強引ではあったものの、ルートの差分をとることができました。
テンプレートのカスタム、Jinja2、カスタムフィルターと違ったアプローチで紹介しました。

個人的にはカスタムフィルターが好きです。

差分がないことを確認したい場合は紹介した内容がそのまま使えるのではないでしょうか。
ただ、出力された差分が想定通りであるか?の確認をする場合は追加の検討が必要です。時間があるときそちらの検討もしたいと思います。

本記事は、辞書から特定のキーを抜き出すことがポイントになっています。
下記の記事でも紹介がされています。

qiita.com

利用した出力結果

after_ip_route.txt

Codes: L - local, C - connected, S - static, R - RIP, M - mobile, B - BGP
       D - EIGRP, EX - EIGRP external, O - OSPF, IA - OSPF inter area 
       N1 - OSPF NSSA external type 1, N2 - OSPF NSSA external type 2
       E1 - OSPF external type 1, E2 - OSPF external type 2
       i - IS-IS, su - IS-IS summary, L1 - IS-IS level-1, L2 - IS-IS level-2
       ia - IS-IS inter area, * - candidate default, U - per-user static route
       o - ODR, P - periodic downloaded static route, H - NHRP, l - LISP
       a - application route
       + - replicated route, % - next hop override, p - overrides from PfR

Gateway of last resort is not set

      10.0.0.0/8 is variably subnetted, 9 subnets, 3 masks
C        10.0.10.0/31 is directly connected, GigabitEthernet0/3
L        10.0.10.0/32 is directly connected, GigabitEthernet0/3
C        10.1.0.0/24 is directly connected, GigabitEthernet0/1
L        10.1.0.1/32 is directly connected, GigabitEthernet0/1
O        10.2.0.0/24 [110/2] via 10.9.0.1, 00:02:10, GigabitEthernet0/0
C        10.9.0.0/31 is directly connected, GigabitEthernet0/0
L        10.9.0.0/32 is directly connected, GigabitEthernet0/0
C        10.100.0.0/24 is directly connected, GigabitEthernet0/2
L        10.100.0.2/32 is directly connected, GigabitEthernet0/2
      172.16.0.0/24 is subnetted, 1 subnets
S        172.16.0.0 [1/0] via 10.9.0.1

before_ip_route.txt

Codes: L - local, C - connected, S - static, R - RIP, M - mobile, B - BGP
       D - EIGRP, EX - EIGRP external, O - OSPF, IA - OSPF inter area 
       N1 - OSPF NSSA external type 1, N2 - OSPF NSSA external type 2
       E1 - OSPF external type 1, E2 - OSPF external type 2
       i - IS-IS, su - IS-IS summary, L1 - IS-IS level-1, L2 - IS-IS level-2
       ia - IS-IS inter area, * - candidate default, U - per-user static route
       o - ODR, P - periodic downloaded static route, H - NHRP, l - LISP
       a - application route
       + - replicated route, % - next hop override, p - overrides from PfR

Gateway of last resort is not set

      10.0.0.0/8 is variably subnetted, 9 subnets, 3 masks
C        10.0.10.0/31 is directly connected, GigabitEthernet0/3
L        10.0.10.0/32 is directly connected, GigabitEthernet0/3
C        10.1.0.0/24 is directly connected, GigabitEthernet0/1
L        10.1.0.1/32 is directly connected, GigabitEthernet0/1
O        10.2.0.0/24 [110/2] via 10.9.0.1, 00:00:03, GigabitEthernet0/0
C        10.9.0.0/31 is directly connected, GigabitEthernet0/0
L        10.9.0.0/32 is directly connected, GigabitEthernet0/0
C        10.100.0.0/24 is directly connected, GigabitEthernet0/2
L        10.100.0.2/32 is directly connected, GigabitEthernet0/2

TTPでパース後の要素が一つだけでもリスト形式にする

usage-automate.hatenablog.com

こちらの記事で、わからなかった下記について実装方法が分かったので書き残します

パース後の要素が2個以上ある場合は自動的にリストになるようです。
要素が1個の場合リストにならないため、パース後に呼び出す際に注意が必要です。

前回のおさらい

作成したテンプレートがこちら

<group name="dynamic_nat">
{{ protocol }} {{ inside_global_ip }}:{{ inside_global_port }} {{ inside_local_ip }}:{{ inside_local_port }} {{ outside_local_ip }}:{{ outside_local_port }} {{ outside_global_ip }}:{{ outside_global_port }}
</group>

<group name="static_nat">
{{ protocol }} {{ inside_global_ip | re("(\S+)(?!.*:)") }} {{ inside_local_ip | re("(\S+)(?!.*:)") }} {{ outside_local_ip | re("(\S+)(?!.*:)") }} {{ outside_global_ip | re("(\S+)(?!.*:)") }}
</group>

たとえばパース後の要素が一つしかできないインプットで実行すると

Pro Inside global      Inside local       Outside local      Outside global
tcp 10.9.0.0:51776     10.1.0.2:51776     10.2.0.2:21        10.2.0.2:21
--- 10.9.0.0     10.1.0.2     ---        ---

アウトプットは辞書になります。

        "dynamic_nat": {
            "inside_global_ip": "10.9.0.0",
            "inside_global_port": "51776",
            "inside_local_ip": "10.1.0.2",
            "inside_local_port": "51776",
            "outside_global_ip": "10.2.0.2",
            "outside_global_port": "21",
            "outside_local_ip": "10.2.0.2",
            "outside_local_port": "21",
            "protocol": "tcp"
        },
        "static_nat": {
            "inside_global_ip": "10.9.0.0",
            "inside_local_ip": "10.1.0.2",
            "outside_global_ip": "---",
            "outside_local_ip": "---",
            "protocol": "---"
        }

パース後の要素が複数個になるインプットで実行すると

Pro Inside global      Inside local       Outside local      Outside global
tcp 10.9.0.0:51776     10.1.0.2:51776     10.2.0.2:21        10.2.0.2:21
tcp 10.9.0.0:51778     10.1.0.2:51778     10.2.0.2:21        10.2.0.2:21
--- 10.9.0.0     10.1.0.2     ---        ---

アウトプットは自動的にリストになります。

        "dynamic_nat": [
            {
                "inside_global_ip": "10.9.0.0",
                "inside_global_port": "51776",
                "inside_local_ip": "10.1.0.2",
                "inside_local_port": "51776",
                "outside_global_ip": "10.2.0.2",
                "outside_global_port": "21",
                "outside_local_ip": "10.2.0.2",
                "outside_local_port": "21",
                "protocol": "tcp"
            },
            {
                "inside_global_ip": "10.9.0.0",
                "inside_global_port": "51778",
                "inside_local_ip": "10.1.0.2",
                "inside_local_port": "51778",
                "outside_global_ip": "10.2.0.2",
                "outside_global_port": "21",
                "outside_local_ip": "10.2.0.2",
                "outside_local_port": "21",
                "protocol": "tcp"
            }
        ],
        "static_nat": {
            "inside_global_ip": "10.9.0.0",
            "inside_local_ip": "10.1.0.2",
            "outside_global_ip": "---",
            "outside_local_ip": "---",
            "protocol": "---"
        }
    }

せっかく構造化したのに、要素が一つの時と複数の時で呼び出し方が変わってしまうと厄介です

要素一個
static_nat.inside_global_ip

要素複数個
static_nat[0].isndie_global_ip

解決方法

テンプレートの<group name="dynamic_nat">*を追加する

<group name="dynamic_nat*">

<group name="dynamic_nat*">
{{ protocol }} {{ inside_global_ip }}:{{ inside_global_port }} {{ inside_local_ip }}:{{ inside_local_port }} {{ outside_local_ip }}:{{ outside_local_port }} {{ outside_global_ip }}:{{ outside_global_port }}
</group>

<group name="static_nat">
{{ protocol }} {{ inside_global_ip | re("(\S+)(?!.*:)") }} {{ inside_local_ip | re("(\S+)(?!.*:)") }} {{ outside_local_ip | re("(\S+)(?!.*:)") }} {{ outside_global_ip | re("(\S+)(?!.*:)") }}
</group>

dynamic_natのほうだけ*を付けました。

これで、パース後の要素が一つのみのインプットで試してみます。

Pro Inside global      Inside local       Outside local      Outside global
tcp 10.9.0.0:51776     10.1.0.2:51776     10.2.0.2:21        10.2.0.2:21
--- 10.9.0.0     10.1.0.2     ---        ---

要素が一つだけですが、*を付けたdynamic_natのほうはリストになりました。

        "dynamic_nat": [
            {
                "inside_global_ip": "10.9.0.0",
                "inside_global_port": "51776",
                "inside_local_ip": "10.1.0.2",
                "inside_local_port": "51776",
                "outside_global_ip": "10.2.0.2",
                "outside_global_port": "21",
                "outside_local_ip": "10.2.0.2",
                "outside_local_port": "21",
                "protocol": "tcp"
            }
        ],
        "static_nat": {
            "inside_global_ip": "10.9.0.0",
            "inside_local_ip": "10.1.0.2",
            "outside_global_ip": "---",
            "outside_local_ip": "---",
            "protocol": "---"
        }
    }

まとめ

TTPを使い始めて地味に困っていた課題を解決できました、いろいろな機能があるのでもう少し勉強をしたいと思います。

ansible-navigator入門

ansible-navigatorとは

ansible-navigator は、インベントリ、プレイブック、コレクションなどのAnsibleコンテンツを作成、確認、トラブルシューティングするためのコマンドベースのツールです。
今回は公式ドキュメントを参考に入門してみました

ansible-navigator.readthedocs.io

インストール

pip install ansible-navigator

インストールをするとansible-runnerもインストールされます、ansible実行はrunnerが行っているようです。

ansible-navigator==1.1.0
ansible-runner==2.1.1

実行

設定ファイルの準備

ansible.cfgの指定やEEの選択などの設定をYAML形式で記載します。
ansible-navigatorを実行するディレクトリに配置しておけば自動的に読み込んでくれます。

ansible-navigator.yml

---
ansible-navigator:
  ansible:
    config: ansible.cfg

  execution-environment:
    container-engine: docker
    image: test
    pull-policy: never
    environment-variables:
      pass:
        - ACI_HOST
        - ACI_USERNAME
        - ACI_PASSWORD
        - ACI_PORT
        - ACI_VALIDATE_CERTS

  playbook-artifact:
    enable: false
    replay: ./artifact/replay.json
    save-as: ./artifact/artifact.json

  # mode: stdout

※今回利用するtest(EE)は事前ansible-builderに作成しています。

ファイルの構成は以下のようにしました

├── ansible.cfg
├── ansible-navigator.yml ★設定ファイル
├── artifact
├── example01.yml
├── example02.yml
├── files
│   └── stdout.txt
├── hosts.ini
└── host_vars
    ├── ios01.yml
    ├── ios02.yml
    └── ios03.yml

コマンドの実行

利用可能なイメージの確認

ansible-navigator images
imagesのサブコマンドを指定することで、EEとして利用可能なimagesが表示されます。
f:id:usage_automate:20211212001203p:plain

番号を指定するとさらに情報が見れます f:id:usage_automate:20211212001247p:plain

  • [0] Image information
    f:id:usage_automate:20211212001446p:plain

  • [1] General information
    f:id:usage_automate:20211212001605p:plain

  • [2] Ansible version and collections f:id:usage_automate:20211212001339p:plain

  • [3] Python packages f:id:usage_automate:20211212001806p:plain

  • [4] Operating system packages f:id:usage_automate:20211212001849p:plain

  • [5] Everything f:id:usage_automate:20211212001922p:plain

inventoryの確認

ansible-navigator inventory -i hosts.ini
inventoryの検索や調査にも便利なinventoryサブコマンドも存在しています。
利用する際は-iでhostsファイルを指定します。
f:id:usage_automate:20211212002234p:plain

  • [0]Browse groups
    f:id:usage_automate:20211212002303p:plain

  • [1] Browse hosts
    f:id:usage_automate:20211212002543p:plain

hostの番号でhost_varsも確認可能
f:id:usage_automate:20211212002447p:plain

モジュールのドキュメントを確認する

ansible-navigator doc ansible.utils.fact_diff
モジュールのオプションを確認するにはdocサブコマンドを利用します。
ドキュメントを確認したいモジュール名を指定し実行します。 f:id:usage_automate:20211212003006p:plain

Collectionを確認する

ansible-navigator collections
EEにインストールされているCollectionを確認するにはcollectionsサブコマンドを利用します。 f:id:usage_automate:20211212003048p:plain

コレクションを選択

f:id:usage_automate:20211212003134p:plain

モジュールを選択

f:id:usage_automate:20211212003156p:plain

Playbookを実行する

ansible-navigator run example01.yml -i hosts.ini
Playbookの実行はrunサブコマンドを利用します。
ansibleで利用できるオプションはどこまで有効か不明ですが--tagを試してみたところ問題なく動作しました。

f:id:usage_automate:20211212004328p:plain

Play name番号を指定する

f:id:usage_automate:20211212004359p:plain

Result番号を指定する

f:id:usage_automate:20211212004420p:plain

ちなみに

intaractiveモードでは、:の後にいろいろなキーワードを指定することができます。

:helpと打ち込むことで、ヘルプを表示できます。 f:id:usage_automate:20211212004601p:plain

:f ^aなど正規表現も可能で検索が柔軟にできそうです(これが結構よさそう)

実行時にできたartifact.jsonから実行ログを見る

ansible-navigator replay artifact/artifact.json

artifactディレクトリに生成したartifact.jsonから実行ログをリプレイすることができます。(接続などは行われません) f:id:usage_automate:20211212004906p:plain

実行するとansible-navigator runを実行したときと同じような操作が可能です。

表示の調整

デフォルトではintaractiveモードとなり、今までのAnsibleの実行ログとはかなり違った表示になっています。 ansibleコマンドを実行したときと同じような表示にするには、modeパラメータをstdoutに変更します。 ansible-navigator.ymlに記載するか、実行時にオプションとして渡すことで変更が可能です。 ansible-navigator.ymlに記載する場合、下記のような表記になります。

ansible-navigator:
  mode: stdout

オプションで変更する場合-m stdoutをコマンド実行時に追加します。

ansible-navigator replay artifact/artifact.json -m stdout

f:id:usage_automate:20211212005231p:plain

見慣れた表示になりました。

collections/inventoryなどはstdoutモードのまま実行するとエラーになります、 stdoutのモードはreplay/runサブコマンドを利用する場合でのみ使うのがよさそうです。

まとめ

ansible-navigatorに入門してみました。個人的にはかなり便利と感じています。
実行のリプレイやEEの設定確認、結果の文字列検索など絶妙にかゆいところに手が届いている感覚です。

今後、ansible-runnerがansibleの実行環境としてスタンダードになっていくことを想像すると使い方を覚えておくといいツールだなと感じました。 (runner実行するより簡単&わかりやすい気がしました)

NW機器SSH/Telnet接続PythonライブラリのScrapliを試してみた

エーピーコミュニケーションズ Advent Calendar 2021 11日目の記事です

qiita.com

Scrapliとは

ネットワーク機器へTelnetまたはSSHの接続するためのPythonライブラリです。
特徴としては、

  1. 簡単に始められる
  2. 速い
  3. 開発が簡単
  4. 拡張性がある(プラグ可能)

トランスポート部分のカスタマイズができるのも特徴です、ssh2やparamiko,telnetなど柔軟に変更ができるようです。しかし、トランスポートはよっぽどの要件がない限り変更しないで十分なようです。

Scrapliのメインリポジトリ github.com

サポートするドライブ(コア)を拡張し様々な機器に対応するためのscrapli_community github.com

Netconf対応すためのscrapli_netconf github.com

Nornirのプライグインとして利用するnornir_scrapli github.com

テストのために「実際の」ネットワークデバイスのように見えるセミインタラクティブSSHサーバーを作成するためのツールscrapli_replay github.com

NW機器の対応状況(2021/12/11時点)

scrapli

scrapli_community

  • aethra
  • alcatel
  • edgecore
  • eltex
  • fortinet
  • hp
  • huawei
  • mikrotik
  • nokia
  • paloalto
  • ruckus
  • simens

Netmikoとの違い

似たようなライブラリにNetmikoがあります。メソッドの構成は似ています(send_command,send_commandsなど)

違いとしては、サポートしているデバイスの数、Netconfのサポートの有無でしょうか。 Netmikoはサポートしているデバイスの数がとても多いです。 github.com

scrapliではまだやり方が分かっていないだけなんですが、Netmikoではredispatchを使ってNW機器の踏み台アクセスもできます。 (SVを踏み台にするのはScrapliでも可能)

どちらも機能としてパーサーと連携もできるため、どちらを使うか?は利用しているデバイスの種類や設定変更によって決めることになりそうです。処理自体もScrapliのほうが速いようです。
手元で比較したときは、close(切断)の処理がNetmikoは数秒かかってしまうのでスクリプトの終了まではScrapliのほうが速いといった結果になりました。

Netmikoは公式ドキュメントがとてもおしゃれです。

環境

Python 3.9.2

scrapli==2021.7.30

インストール

最低限のインストール

pip install scrapli

他の機能を使う場合に、ライブラリをまとめてインストール

pip install scrapli[full]
pip install scrapli[paramiko]
pip install scrapli[ssh2]
pip install scrapli[asyncssh]
pip install scrapli[textfsm]
pip install scrapli[genie]
pip install scrapli[ttp]

使ってみる

ログイン/ログアウト

from scrapli import Scrapli

device = {
   "host": "sandbox-iosxe-latest-1.cisco.com",
   "auth_username": "developer",
   "auth_password": "XXXXXXXXX",
   "auth_strict_key": False,
   "platform": "cisco_iosxe"
}

conn = Scrapli(**device)
conn.open()
print(conn.get_prompt())
conn.close()

platformを指定することで対応するDriverを選択してくれるようです。
platformを指定せずにIOSXEDriver()を利用しても、同じ挙動になります。
接続はopen()でログアウトはclose()で実施します。

get_prompt()はその名の通りプロンプトを取得してくれます

Scrapli# python example.py 
csr1000v-1#

showコマンドの実行

続いて、ログイン後showコマンドを実行してみます。 send_command()/send_commandsを利用します。 ter len 0など表示に関する設定は自動的に行われるため、showコマンドのみを指定します。

send_command: 1コマンド実行

from scrapli import Scrapli
import pprint

device = {
   "host": "sandbox-iosxe-latest-1.cisco.com",
   "auth_username": "developer",
   "auth_password": "XXXXXXXXX",
   "auth_strict_key": False,
   "platform": "cisco_iosxe"
}

with Scrapli(**device) as conn:
   res = conn.send_command("show ip interface brief")


print(res.start_time)
print(res.result)
print(res.finish_time)

resultに実行結果が格納されているので結果の確認はres.resultを表示させます。

# python show.py 
2021-12-11 12:21:18.694178
Interface              IP-Address      OK? Method Status                Protocol
GigabitEthernet1       10.10.20.48     YES NVRAM  up                    up
GigabitEthernet2       192.168.1.1     YES other  up                    up
GigabitEthernet3       unassigned      YES NVRAM  administratively down down
Loopback1              56.56.56.56     YES manual up                    up
2021-12-11 12:21:18.943480

send_commands: 複数コマンド実行 複数のコマンドを実行するにはsend_commands()を利用します。
commands引数にリスト形式でshowコマンドを指定します。

from scrapli import Scrapli
import pprint

device = {
   "host": "sandbox-iosxe-latest-1.cisco.com",
   "auth_username": "developer",
   "auth_password": "XXXXXXXXX",
   "auth_strict_key": False,
   "platform": "cisco_iosxe"
}

with Scrapli(**device) as conn:
   res = conn.send_commands(commands=["show ip interface brief",
                                      "show version"])
print(res.result) # すべて表示
print(res[0].result) # 1つ目の要素(show ip interface brief)を表示
print(res[1].result) # 2つ目の要素(show version)を表示

実行結果も、リストになっているためX番目の結果を表示するにはres[X].resultと指定します。

python show2.py 
# すべて表示
show ip interface brief <- 実行したコマンドも含まれる
Interface              IP-Address      OK? Method Status                Protocol
GigabitEthernet1       10.10.20.48     YES NVRAM  up                    up
GigabitEthernet2       192.168.1.1     YES other  up                    up
GigabitEthernet3       unassigned      YES NVRAM  administratively down down
Loopback1              56.56.56.56     YES manual up                    upshow version <- 実行したコマンドも含まれる
Cisco IOS XE Software, Version 17.03.01a
Cisco IOS Software [Amsterdam], Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 17.3.1a, RELEASE SOFTWARE (fc3)
Technical Support: http://www.cisco.com/techsupport
Copyright (c) 1986-2020 by Cisco Systems, Inc.
Compiled Wed 12-Aug-20 00:16 by mcpre


Cisco IOS-XE software, Copyright (c) 2005-2020 by cisco Systems, Inc.
All rights reserved.  Certain components of Cisco IOS-XE software are
licensed under the GNU General Public License ("GPL") Version 2.0.  The
software code licensed under GPL Version 2.0 is free software that comes
with ABSOLUTELY NO WARRANTY.  You can redistribute and/or modify such
GPL code under the terms of GPL Version 2.0.  For more details, see the
documentation or "License Notice" file accompanying the IOS-XE software,
or the applicable URL provided on the flyer accompanying the IOS-XE
software.


ROM: IOS-XE ROMMON
csr1000v-1 uptime is 1 day, 3 hours, 57 minutes
Uptime for this control processor is 1 day, 3 hours, 58 minutes
System returned to ROM by reload
System image file is "bootflash:packages.conf"
Last reload reason: reload



This product contains cryptographic features and is subject to United
States and local country laws governing import, export, transfer and
use. Delivery of Cisco cryptographic products does not imply
third-party authority to import, export, distribute or use encryption.
Importers, exporters, distributors and users are responsible for
compliance with U.S. and local country laws. By using this product you
agree to comply with applicable laws and regulations. If you are unable
to comply with U.S. and local laws, return this product immediately.

A summary of U.S. laws governing Cisco cryptographic products may be found at:
http://www.cisco.com/wwl/export/crypto/tool/stqrg.html

If you require further assistance please contact us by sending email to
export@cisco.com.

License Level: ax
License Type: N/A(Smart License Enabled)
Next reload license Level: ax

The current throughput level is 1000 kbps


Smart Licensing Status: UNREGISTERED/No Licenses in Use

cisco CSR1000V (VXE) processor (revision VXE) with 715705K/3075K bytes of memory.
Processor board ID 9ESGOBARV9D
Router operating mode: Autonomous
3 Gigabit Ethernet interfaces
32768K bytes of non-volatile configuration memory.
3978420K bytes of physical memory.
6188032K bytes of virtual hard disk at bootflash:.

Configuration register is 0x2102
# 1つ目の要素(show ip interface brief)を表示
Interface              IP-Address      OK? Method Status                Protocol
GigabitEthernet1       10.10.20.48     YES NVRAM  up                    up
GigabitEthernet2       192.168.1.1     YES other  up                    up
GigabitEthernet3       unassigned      YES NVRAM  administratively down down
Loopback1              56.56.56.56     YES manual up                    up

# 2つ目の要素(show version)を表示
Cisco IOS XE Software, Version 17.03.01a
Cisco IOS Software [Amsterdam], Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 17.3.1a, RELEASE SOFTWARE (fc3)
Technical Support: http://www.cisco.com/techsupport
Copyright (c) 1986-2020 by Cisco Systems, Inc.
Compiled Wed 12-Aug-20 00:16 by mcpre


Cisco IOS-XE software, Copyright (c) 2005-2020 by cisco Systems, Inc.
All rights reserved.  Certain components of Cisco IOS-XE software are
licensed under the GNU General Public License ("GPL") Version 2.0.  The
software code licensed under GPL Version 2.0 is free software that comes
with ABSOLUTELY NO WARRANTY.  You can redistribute and/or modify such
GPL code under the terms of GPL Version 2.0.  For more details, see the
documentation or "License Notice" file accompanying the IOS-XE software,
or the applicable URL provided on the flyer accompanying the IOS-XE
software.


ROM: IOS-XE ROMMON
csr1000v-1 uptime is 1 day, 3 hours, 57 minutes
Uptime for this control processor is 1 day, 3 hours, 58 minutes
System returned to ROM by reload
System image file is "bootflash:packages.conf"
Last reload reason: reload



This product contains cryptographic features and is subject to United
States and local country laws governing import, export, transfer and
use. Delivery of Cisco cryptographic products does not imply
third-party authority to import, export, distribute or use encryption.
Importers, exporters, distributors and users are responsible for
compliance with U.S. and local country laws. By using this product you
agree to comply with applicable laws and regulations. If you are unable
to comply with U.S. and local laws, return this product immediately.

A summary of U.S. laws governing Cisco cryptographic products may be found at:
http://www.cisco.com/wwl/export/crypto/tool/stqrg.html

If you require further assistance please contact us by sending email to
export@cisco.com.

License Level: ax
License Type: N/A(Smart License Enabled)
Next reload license Level: ax

The current throughput level is 1000 kbps


Smart Licensing Status: UNREGISTERED/No Licenses in Use

cisco CSR1000V (VXE) processor (revision VXE) with 715705K/3075K bytes of memory.
Processor board ID 9ESGOBARV9D
Router operating mode: Autonomous
3 Gigabit Ethernet interfaces
32768K bytes of non-volatile configuration memory.
3978420K bytes of physical memory.
6188032K bytes of virtual hard disk at bootflash:.

Configuration register is 0x2102

パーサーと組み合わせる

CLIのテキストデータを構造化するために、パースすることもできます。

  • textfsm_parse_output
  • ttp_parse_output
  • genie_parse_output
from scrapli import Scrapli
import pprint

device = {
   "host": "sandbox-iosxe-latest-1.cisco.com",
   "auth_username": "developer",
   "auth_password": "XXXXXXXXX",
   "auth_strict_key": False,
   "platform": "cisco_iosxe"
}

with Scrapli(**device) as conn:
   res = conn.send_commands(commands=["show ip interface brief",
                                      "show version"])


[ pprint.pprint(i.textfsm_parse_output()) for i in res]
[ pprint.pprint(i.genie_parse_output()) for i in res]

textfsmパーサとgenieパーサを試します。事前にntc-templates,genieのライブラリをインストールしておきます。 この2つのパーサはテンプレートを指定せずにすぐに利用が可能です。

TTPは利用するテンプレートを指定する必要があるためひと手間必要なので割愛します。

## textfsm(ntc-template): show interface brief
[{'intf': 'GigabitEthernet1',
  'ipaddr': '10.10.20.48',
  'proto': 'up',
  'status': 'up'},
 {'intf': 'GigabitEthernet2',
  'ipaddr': '192.168.1.1',
  'proto': 'up',
  'status': 'up'},
 {'intf': 'GigabitEthernet3',
  'ipaddr': 'unassigned',
  'proto': 'down',
  'status': 'administratively down'},
 {'intf': 'Loopback1', 'ipaddr': '56.56.56.56', 'proto': 'up', 'status': 'up'}]

## textfsm(ntc-template): show version
[{'config_register': '0x2102',
  'hardware': ['CSR1000V'],
  'hostname': 'csr1000v-1',
  'mac': [],
  'reload_reason': 'reload',
  'restarted': '',
  'rommon': 'IOS-XE',
  'running_image': 'packages.conf',
  'serial': ['9ESGOBARV9D'],
  'uptime': '1 day, 4 hours, 36 minutes',
  'uptime_days': '1',
  'uptime_hours': '4',
  'uptime_minutes': '36',
  'uptime_weeks': '',
  'uptime_years': '',
  'version': '17.3.1a'}]

## genie: show interface brief
{'interface': {'GigabitEthernet1': {'interface_is_ok': 'YES',
                                    'ip_address': '10.10.20.48',
                                    'method': 'NVRAM',
                                    'protocol': 'up',
                                    'status': 'up'},
               'GigabitEthernet2': {'interface_is_ok': 'YES',
                                    'ip_address': '192.168.1.1',
                                    'method': 'other',
                                    'protocol': 'up',
                                    'status': 'up'},
               'GigabitEthernet3': {'interface_is_ok': 'YES',
                                    'ip_address': 'unassigned',
                                    'method': 'NVRAM',
                                    'protocol': 'down',
                                    'status': 'administratively down'},
               'Loopback1': {'interface_is_ok': 'YES',
                             'ip_address': '56.56.56.56',
                             'method': 'manual',
                             'protocol': 'up',
                             'status': 'up'}}}

## genie: show version
{'version': {'chassis': 'CSR1000V',
             'chassis_sn': '9ESGOBARV9D',
             'compiled_by': 'mcpre',
             'compiled_date': 'Wed 12-Aug-20 00:16',
             'curr_config_register': '0x2102',
             'disks': {'bootflash:.': {'disk_size': '6188032',
                                       'type_of_disk': 'virtual hard disk'}},
             'hostname': 'csr1000v-1',
             'image_id': 'X86_64_LINUX_IOSD-UNIVERSALK9-M',
             'image_type': 'production image',
             'label': 'RELEASE SOFTWARE (fc3)',
             'last_reload_reason': 'reload',
             'license_level': 'ax',
             'license_type': 'N/A(Smart License Enabled)',
             'main_mem': '715705',
             'mem_size': {'non-volatile configuration': '32768',
                          'physical': '3978420'},
             'next_reload_license_level': 'ax',
             'number_of_intfs': {'Gigabit Ethernet': '3'},
             'os': 'IOS-XE',
             'platform': 'Virtual XE',
             'processor_type': 'VXE',
             'returned_to_rom_by': 'reload',
             'rom': 'IOS-XE ROMMON',
             'router_operating_mode': 'Autonomous',
             'rtr_type': 'CSR1000V',
             'system_image': 'bootflash:packages.conf',
             'uptime': '1 day, 4 hours, 36 minutes',
             'uptime_this_cp': '1 day, 4 hours, 38 minutes',
             'version': '17.3.1a',
             'version_short': '17.3',
             'xe_version': '17.03.01a'}}

パースも簡単にできます。

設定変更

send_configsで設定を送ることができます。 configureモードへの変更などはドライバー側で入力してくれるため、設定のみを指定します。

from scrapli import Scrapli
import pprint

device = {
   "host": "sandbox-iosxe-latest-1.cisco.com",
   "auth_username": "developer",
   "auth_password": "XXXXXXXXX",
   "auth_strict_key": False,
   "platform": "cisco_iosxe"
}

with Scrapli(**device) as conn:
   res_configure = conn.send_configs(["interface loopback100", "description configured by scrapli"])

print(res_configure.result)

resultには実行したコマンドと実行時の標準出力が格納されています。

python configure1.py 
interface loopback100
description configured by scrapli

存在しないリソースを消そうとした。などでエラーメッセージが出た場合もresultに格納されます

python configure_back.py  # loopback100がない状態で削除をしようとした
no interface loopback100
                                        ^
% Invalid input detected at '^' marker.

ここまでの要素を組み合わせて、 参照→変更→参照の流れを実施します。 設定前と設定後のshow interfaceを取得しパースしてテーブルにします。 テーブル作成のため、numpyとtabulateを利用します。

from scrapli import Scrapli
import tabulate
import numpy as np

device = {
   "host": "sandbox-iosxe-latest-1.cisco.com",
   "auth_username": "developer",
   "auth_password": "XXXXXXXXX",
   "auth_strict_key": False,
   "platform": "cisco_iosxe"
}

with Scrapli(**device) as conn:
   res_before = conn.send_command("show interfaces")
   res_configure = conn.send_configs(["interface loopback100", "description configured by scrapli"])
   res_after = conn.send_command("show interfaces")

parsed_before = res_before.genie_parse_output()
parsed_after = res_after.genie_parse_output()


## NOTE: 出力結果をテーブルにする処理
after_interface_list = list()
after_description_list = list()
before_interface_list = list()
before_description_list = list()

[before_interface_list.append(i) for i in parsed_before]
[before_description_list.append(parsed_after[i]['description']) for i in parsed_before]

[after_interface_list.append(i) for i in parsed_after]
[after_description_list.append(parsed_after[i]['description']) for i in parsed_after]

headers = ["interface", "description"]
table_before = [before_interface_list, before_description_list]
table_after= [after_interface_list, after_description_list]

res_table_before = tabulate.tabulate(np.array(table_before).transpose(), headers,tablefmt="grid")
res_table_after = tabulate.tabulate(np.array(table_after).transpose(), headers,tablefmt="grid")

## NOTE: テーブルの表示
print("==BEFORE==")
print(res_table_before)

print("==AFTER==")
print(res_table_after)

実行します

python configure.py 
==BEFORE==
+------------------+---------------------------------------+
| interface        | description                           |
+==================+=======================================+
| GigabitEthernet1 | MANAGEMENT INTERFACE - DON'T TOUCH ME |
+------------------+---------------------------------------+
| GigabitEthernet2 | Configured by RESTCONF                |
+------------------+---------------------------------------+
| GigabitEthernet3 | Network Interface                     |
+------------------+---------------------------------------+
| Loopback1        | This is a test                        |
+------------------+---------------------------------------+
==AFTER==
+------------------+---------------------------------------+
| interface        | description                           |
+==================+=======================================+
| GigabitEthernet1 | MANAGEMENT INTERFACE - DON'T TOUCH ME |
+------------------+---------------------------------------+
| GigabitEthernet2 | Configured by RESTCONF                |
+------------------+---------------------------------------+
| GigabitEthernet3 | Network Interface                     |
+------------------+---------------------------------------+
| Loopback1        | This is a test                        |
+------------------+---------------------------------------+
| Loopback100      | configured by scrapli                 |
+------------------+---------------------------------------+

変更前と変更後のテーブルを表示してみました、Loopback100が追加されたことも確認できます。

Netconf

最後にScrapliの特徴の一つでもあるNetconfにドライバーも試してみます。

事前にscrapli-netconfをインストールしておきます。

pip install scrapli-netconf

スクリプト作成

from scrapli_netconf.driver import NetconfDriver

device = {
   "host": "sandbox-iosxe-latest-1.cisco.com",
   "auth_username": "developer",
   "auth_password": "XXXXXXXXX",
   "auth_strict_key": False,
   "port": 830,
}

INTERFACE_FILTER = """
          <if:interfaces xmlns:if="urn:ietf:params:xml:ns:yang:ietf-interfaces">
          </if:interfaces>
          """

with NetconfDriver(**device) as nc_conn:
  response= nc_conn.get(filter_=INTERFACE_FILTER)

print(response.result)

Interfaeの情報を取得します。

実行

python netconf_get.py 
<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="101">
  <data>
    <interfaces xmlns="urn:ietf:params:xml:ns:yang:ietf-interfaces">
      <interface>
        <name>GigabitEthernet1</name>
        <description>MANAGEMENT INTERFACE - DON'T TOUCH ME</description>
        <type xmlns:ianaift="urn:ietf:params:xml:ns:yang:iana-if-type">ianaift:ethernetCsmacd</type>
        <enabled>true</enabled>
        <ipv4 xmlns="urn:ietf:params:xml:ns:yang:ietf-ip">
          <address>
            <ip>10.10.20.48</ip>
            <netmask>255.255.255.0</netmask>
          </address>
        </ipv4>
        <ipv6 xmlns="urn:ietf:params:xml:ns:yang:ietf-ip"/>
      </interface>
      <interface>
        <name>GigabitEthernet2</name>
        <description>Configured by RESTCONF</description>
        <type xmlns:ianaift="urn:ietf:params:xml:ns:yang:iana-if-type">ianaift:ethernetCsmacd</type>
        <enabled>true</enabled>
        <ipv4 xmlns="urn:ietf:params:xml:ns:yang:ietf-ip">
          <address>
            <ip>192.168.1.1</ip>
            <netmask>255.255.255.252</netmask>
          </address>
        </ipv4>
        <ipv6 xmlns="urn:ietf:params:xml:ns:yang:ietf-ip"/>
      </interface>
      <interface>
        <name>GigabitEthernet3</name>
        <description>Network Interface</description>
        <type xmlns:ianaift="urn:ietf:params:xml:ns:yang:iana-if-type">ianaift:ethernetCsmacd</type>
        <enabled>false</enabled>
        <ipv4 xmlns="urn:ietf:params:xml:ns:yang:ietf-ip"/>
        <ipv6 xmlns="urn:ietf:params:xml:ns:yang:ietf-ip"/>
      </interface>
      <interface>
        <name>Loopback1</name>
        <description>This is a test</description>
        <type xmlns:ianaift="urn:ietf:params:xml:ns:yang:iana-if-type">ianaift:softwareLoopback</type>
        <enabled>true</enabled>
        <ipv4 xmlns="urn:ietf:params:xml:ns:yang:ietf-ip">
          <address>
            <ip>56.56.56.56</ip>
            <netmask>255.255.255.255</netmask>
          </address>
        </ipv4>
        <ipv6 xmlns="urn:ietf:params:xml:ns:yang:ietf-ip"/>
      </interface>
      <interface>
        <name>Loopback100</name>
        <description>configured by scrapli</description>
        <type xmlns:ianaift="urn:ietf:params:xml:ns:yang:iana-if-type">ianaift:softwareLoopback</type>
        <enabled>true</enabled>
        <ipv4 xmlns="urn:ietf:params:xml:ns:yang:ietf-ip"/>
        <ipv6 xmlns="urn:ietf:params:xml:ns:yang:ietf-ip"/>
      </interface>
      <interface>
        <name>Loopback101</name>
        <type xmlns:ianaift="urn:ietf:params:xml:ns:yang:iana-if-type">ianaift:softwareLoopback</type>
        <enabled>true</enabled>
        <ipv4 xmlns="urn:ietf:params:xml:ns:yang:ietf-ip">
          <address>
            <ip>101.101.101.101</ip>
            <netmask>255.255.255.255</netmask>
          </address>
        </ipv4>
        <ipv6 xmlns="urn:ietf:params:xml:ns:yang:ietf-ip"/>
      </interface>
    </interfaces>
  </data>
</rpc-reply>

情報取得できることを確認できました。

設定変更(edit)やコンフィグ取得(get_config)などのメソッドも用意されています。

まとめ

Scrapliに入門してみました。Netmikoを触ったことがあったので抵抗なく始めることができました、それくらい似ていると思います。
DriverにはAsync用のものもあり非同期処理にも対応しているようです。 標準ライブラリconcurrent.futuresを使って平行処理も実装してみましたが、軽量でサクサク動くので便利です。

興味があればぜひ試してみてください。

Golangで書かれたScrapligoもあるようです。 github.com

GitlabRunnerインストールしてGitlabCIを始める

この記事はGitLab Advent Calendar 2021の10日目の記事です

qiita.com

やりたかったこと

自動化関連の仕事を始めて、
AnsibleのPlaybookを管理するのにGitlabを利用することが増えてきました。
ただ、GitlabCIはあまり利用できていなかったので、勉強もかねて構築してみました。

環境

サーバ(AWSで構築)

Gitlab

  • gitlab/gitlab-ee: 14.4.2-ee
  • gitlab/gitlab-runner: 14.4.0

サーバーにGitlabのみ構築している状態です。
f:id:usage_automate:20211210091214p:plain

GitlabRunnerインストール

GitlabRunnerをインストールします。 公式ドキュメントを参考にdocker runを実施します。

# docker run -d --name gitlab-runner --restart always \
>      -v /srv/gitlab-runner/config:/etc/gitlab-runner \
>      -v /var/run/docker.sock:/var/run/docker.sock \
>      gitlab/gitlab-runner:latest

起動していることの確認

docker ps
CONTAINER ID   IMAGE                                 COMMAND                    CREATED        STATUS                    PORTS                       NAMES
23e7207ac5a8   gitlab/gitlab-runner:latest   "/usr/bin/dumb-init …"   3 weeks ago   Up 34 minutes                                         gitlab-runner

GitlabRunner登録

続いて、GitlabRunnerをGitlabに登録します。
f:id:usage_automate:20211210091647p:plain

事前にGitlab側でトークンを確認します。
管理者エリアのrunnerの設定にトークンが記載されています。

f:id:usage_automate:20211210085650p:plain

docker runコマンドでGitlab RunnerからGitlabへの登録を実施します。
登録時に、runner名やタイプ(docker,shellなど)、Dockerの場合は利用するImageなどを対話形式で入力します。 ★がついてるものが入力したもの (コマンド実行時にオプションで指定することもできるようです。)

# docker run --rm -it -v /srv/gitlab-runner/config:/etc/gitlab-runner gitlab/gitlab-runner register
Runtime platform                                    arch=amd64 os=linux pid=7 revision=4b9e985a version=14.4.0
Running in system-mode.                            
                                                   
Enter the GitLab instance URL (for example, https://gitlab.com/):
http://XXXXXXXXXXX ★
Enter the registration token:
XXXXXXXXXXXXXXXXXX ★
Enter a description for the runner:
[fa6965628a91]: 
Enter tags for the runner (comma-separated):

Registering runner... succeeded                     runner=KxYfjwgb
Enter an executor: custom, docker, docker-ssh, shell, docker+machine, docker-ssh+machine, parallels, ssh, virtualbox, kubernetes:
docker ★
Enter the default Docker image (for example, ruby:2.6):
ubuntu ★
Runner registered successfully. Feel free to start it, but if it's running already the config should be automatically reloaded! 

successfullyが表示され登録が完了すると、Gitlabの管理者エリアのRunnerの設定(トークンを確認したページ)にRunnerが表示されます。 f:id:usage_automate:20211210085712p:plain

Runnerをクリックして、利用するプロジェクトを設定します。 f:id:usage_automate:20211210085817p:plain

有効化後 f:id:usage_automate:20211210085845p:plain

Gitlab runner設定変更

Runnerの登録が完了しましたが、このままCIを実行すると失敗してしまいます。 CIをするコンテナ(CIコンテナ)からGitlabのExternal IPへアクセスできないことが原因でした。 f:id:usage_automate:20211210090832p:plain

runnerの設定ファイルを編集します。

# cd /srv/gitlab-runner/config
# cp config.toml config.toml .org
# vi config.toml

clone_urlとpull_policyの設定を追加しました。
clone_urlは文字通りcloneする際に利用するurlになります。今回はこちらを内部IPに変更しました。
pull_policyはneverにすることでローカルのイメージを利用できます。

concurrent = 1
check_interval = 0

[session_server]
  session_timeout = 1800

[[runners]]
  name = "fa6965628a91"
  url = "http://XXXXXXXXXXXXX"
  token = "XXXXXXXXXXXXXXX"
  executor = "docker"
  clone_url = "http://YYYYYYYY" ★追記
  [runners.custom_build_dir]
  [runners.cache]
    [runners.cache.s3]
    [runners.cache.gcs]
    [runners.cache.azure]
  [runners.docker]
    tls_verify = false
    image = "ubuntu"
    privileged = false
    disable_entrypoint_overwrite = false
    oom_kill_disable = false
    disable_cache = false
    volumes = ["/cache"]
    shm_size = 0
    pull_policy = "never" ★追記

これでRunnerの設定は完了です。cloneも問題なくできるようになりました。 f:id:usage_automate:20211210090403p:plain

Dockerイメージの作成

ここからはDockerのイメージを作成し、GitlabCIを動かしてみます。

下記Dockerfileを用意したのでビルドします。

FROM ubuntu
RUN apt-get update -y
RUN apt-get install python-pip -y
RUN pip install ansible-core
RUN pip install ansible-lint
RUN pip install yamllint

ビルドのログ

# docker build . -t test_image
Sending build context to Docker daemon   25.6kB
Step 1/6 : FROM ubuntu
 ---> ba6acccedd29
Step 2/6 : RUN apt-get update -y
 ---> Using cache
~snip~
Successfully built fe72d7eaaf20
Successfully tagged test_image:latest

# docker images
REPOSITORY             TAG       IMAGE ID       CREATED          SIZE
test_image             latest    fe72d7eaaf20   10 seconds ago   473MB
~ snip ~

ビルドが完了しローカルにDockerイメージがあることを確認出来たらgitlabにアップするリポジトリ内に.gitlan-ci.ymlを書いていきます。
.gitlab-ci.ymlはテストの内容を記載したYamlファイルです、stageなどを利用し分割したり条件分岐させたりすることも可能なようです。

.gitlab-ci.yml

image: test_image

stages:
  - lint

lint-job:
  stage: lint
  script:
    - echo "$GITLAB_USER_LOGIN!"

準備が完了したので、gitlabのプロジェクトを更新しCIが動くかを確認します。

f:id:usage_automate:20211210093810p:plain

プロジェクト -> CI/CD -> パイプラインを確認し、ステータスが成功し処理が動いていることを確認できました。

ちなみに、config.tomlでclone_urlの設定をしていないとExternal_IPにアクセスして失敗しているログは以下です。 f:id:usage_automate:20211210094316p:plain

clone_urlの設定見つけるまで時間がかかってしまって苦労しました・・
(そもそもちゃんとexternal_ipの設定をちゃんとしていないのも悪い)

まとめ

GitlabCIを動かしてみる!といった入門的な内容でしたが、それなりに苦労しました。
そもそも、GitlabRunnerはコンテナでさらにCIを動かす別のコンテナがあることも理解ができていなかったですが、作ってみることで少しは理解できたかなぁといったところです。

参考にさせていただいたサイト

今見るとやってることほぼ参考にさせてもらってる神ブログ

zaki-hmkc.hatenablog.com

公式ドキュメント

docs.gitlab.com

docs.gitlab.com

作成したtemplateをntc-templatesにPRする

こちらは エーピーコミュニケーションズ Advent Calendar 2021 4日目の記事です qiita.com

はじめに

今年の個人的な目標はOSS参加でした。 結果的に数件ですがの活動ができました。
直近に実施したntc-templatesの追加について実施した内容をまとめてみました。

ことの発端

やったこと

新規テンプレートを作成して、ntc-templatesにプルリクエストを出します

github.com

前回の記事でテンプレートの作成を解説しています。 usage-automate.hatenablog.com

前回の記事で作成してshow ip nat translationsのテンプレートを改修してプルリクエストを出します。

OSSの参加自体あまり慣れてません、お作法的によろしくないことがあれば優しくおしらせください

手順

手順の流れはざっくりと以下になります。
1. Githubでの作業
2. ローカル(自端末)での作業
3. テンプレート作成
4. テスト作成
5. テスト
6. コミット&プッシュ
7. プルリクエスト作成
8. マージされる

1. Githubでの作業

ntc-templatesのリポジトリをフォークし作業をします。
まずは、右上のフォークボタンを押します。 f:id:usage_automate:20211203091503p:plain

フォーク後 f:id:usage_automate:20211203091605p:plain

ksaegusa/ntc-templatesになってますね これで準備完了で、ローカルで開発を始めます。

2. ローカル(自端末)での作業

フォークしたリポジトリをクローンしローカルでの作業を開始ます。

git clone https://github.com/ksaegusa/ntc-templates.git

ブランチの作成と移動

git branch add-cisco-show-ip-nat-translations
git checkout add-cisco-show-ip-nat-translations

ブランチ名は、ほかのコントリビュータの方を参考に命名しました。
※マージ済みのプルリクエストなどから雰囲気をつかむ。

3. テンプレート作成

show ip nat translationsのテンプレートを追加していきます。 さらにテンプレートとの紐付けが書かれているindexファイルの更新も合わせて実施します。

新規作成ファイル

  • ntc_templates/templates/cisco_ios_show_ip_nat_translations.textfsm
Value PROTOCOL (tcp|udp|icmp|---)
Value INSIDE_GLOBAL_IP (\d+\.\d+\.\d+\.\d+|---)
Value INSIDE_GLOBAL_PORT (\S+)
Value INSIDE_LOCAL_IP (\d+\.\d+\.\d+\.\d+|---)
Value INSIDE_LOCAL_PORT (\S+)
Value OUTSIDE_LOCAL_IP (\d+\.\d+\.\d+\.\d+|---)
Value OUTSIDE_LOCAL_PORT (\S+)
Value OUTSIDE_GLOBAL_IP (\d+\.\d+\.\d+\.\d+|---)
Value OUTSIDE_GLOBAL_PORT (\S+)

Start
  ^Pro\s+Inside\sglobal\s+Inside\slocal\s+Outside\slocal\s+Outside\sglobal
  ^${PROTOCOL}\s+${INSIDE_GLOBAL_IP}:${INSIDE_GLOBAL_PORT}\s+${INSIDE_LOCAL_IP}:${INSIDE_LOCAL_PORT}\s+${OUTSIDE_LOCAL_IP}:${OUTSIDE_GLOBAL_PORT}\s+${OUTSIDE_GLOBAL_IP}:${OUTSIDE_LOCAL_PORT} -> Record
  ^${PROTOCOL}\s+${INSIDE_GLOBAL_IP}\s+${INSIDE_LOCAL_IP}\s+${OUTSIDE_LOCAL_IP}\s+${OUTSIDE_GLOBAL_IP} -> Record

作成したテンプレートをtemplatesディレクトリ配下に格納します。 前回の記事で紹介したときよりパワーアップしています。

前回のテンプレートを使ったアウトプット
 - protocol: 'tcp'
   inside_global: '10.9.0.0:51776',
   inside_local: '10.1.0.2:51776',
   outside_global: '10.2.0.2:21',
   outside_local: '10.2.0.2:21',

今回のテンプレート
  - protocol: "tcp"
    inside_global_ip: "10.9.0.0"
    inside_global_port: "51776"
    inside_local_ip: "10.1.0.2"
    inside_local_port: "51776"
    outside_local_ip: "10.2.0.2"
    outside_local_port: "21"
    outside_global_ip: "10.2.0.2"
    outside_global_port: "21"

IPの情報とポートの情報がひとまとまりになってしまっていたのでこちらを分離できるようにしました。

修正ファイル

  • ntc_templates/templates/index
~snip~
cisco_ios_show_ipv6_interface_brief.textfsm, .*, cisco_ios, sh[[ow]] ipv[[6]] i[[nterface]] b[[rief]]
cisco_ios_show_ip_nat_translations.textfsm, .*, cisco_ios, sh[[ow]] ip nat translation[[s]]
cisco_ios_show_ip_eigrp_neighbors.textfsm, .*, cisco_ios, sh[[ow]] ip ei[[grp]] nei[[ghbors]]
~snip~

続いて、既存のファイルの更新をします。indexはコマンドとテンプレートを紐づけをするようファイルのようです。 READMEに更新の際の注意が書いてありました。

  • アルファベット順のOS
  • 長さ順にテンプレート名
  • 長さが同じ場合は、コマンド名のアルファベット順を使用してください
  • OS間のスペースを確保する

今回はcisco,iosのコマンド追加なのでcisco_ios_show_xxxが並んでいる部分に追記をします。その際テンプレート名の長い順に並べるようです。 cisco_ios_show_ipv6_interface_brief.textfsmとcisco_ios_show_ip_eigrp_neighbors.textfsmの間に追加したテンプレート情報を追加しました。

cisco_ios_show_ipv6_interface_brief.textfsm => 43文字
cisco_ios_show_ip_nat_translations.textfsm = > 42文字 ★この位置に差し込み
cisco_ios_show_ip_eigrp_neighbors.textfsm => 41文字

後半部分は、以下のルールになっているようです。

.*,ベンダ_OS, コマンド省略可能な個所

今回は省略可能な個所が開発時に理解できていなかったためsだけになってます。。
実際にはsh ip na tでもコマンドが通るため、下記のように書く方がよかったです。 f:id:usage_automate:20211204092607p:plain

cisco_ios_show_ip_nat_translations.textfsm, .*, cisco_ios, sh[[ow]] ip n[[at]] t[[ranslations]]

タイミングを見計らって直しちゃおうと思います。

これで、テンプレートの作成とindexの更新が完了です。

4. テスト作成

さて、今回は合わせてテストも書いてあげる必要があるようです。 testsディレクトリの配下にベンダ_OS命名で作成されたディレクトリがあります。さらにその配下にコマンド名でディレクトリを作成しその中に必要なファイルを用意します。 ほかのディレクトリを参考に、コマンド実行時に出力されたものを記載したファイル(.raw)とパース後の想定Yamlファイルを用意します。

新規作成

  • tests/cisco_ios/show_ip_nat_translations/show_ip_nat_translations.raw
Pro Inside global      Inside local       Outside local      Outside global
tcp 10.9.0.0:51776     10.1.0.2:51776     10.2.0.2:21        10.2.0.2:21
tcp 10.9.0.0:51778     10.1.0.2:51778     10.2.0.2:21        10.2.0.2:21
tcp 10.9.0.0:56384     10.1.0.2:56384     10.2.0.2:22        10.2.0.2:22
icmp 10.9.0.0:513     10.1.0.2:512     10.2.0.2:512        10.2.0.2:513
--- 10.9.0.0     10.1.0.2     ---        ---
  • tests/cisco_ios/show_ip_nat_translations/show_ip_nat_translations.yml
---
parsed_sample:
  - protocol: "tcp"
    inside_global_ip: "10.9.0.0"
    inside_global_port: "51776"
    inside_local_ip: "10.1.0.2"
    inside_local_port: "51776"
    outside_local_ip: "10.2.0.2"
    outside_local_port: "21"
    outside_global_ip: "10.2.0.2"
    outside_global_port: "21"
  - protocol: "tcp"
    inside_global_ip: "10.9.0.0"
    inside_global_port: "51778"
    inside_local_ip: "10.1.0.2"
    inside_local_port: "51778"
    outside_local_ip: "10.2.0.2"
    outside_local_port: "21"
    outside_global_ip: "10.2.0.2"
    outside_global_port: "21"
  - protocol: "tcp"
    inside_global_ip: "10.9.0.0"
    inside_global_port: "56384"
    inside_local_ip: "10.1.0.2"
    inside_local_port: "56384"
    outside_local_ip: "10.2.0.2"
    outside_local_port: "22"
    outside_global_ip: "10.2.0.2"
    outside_global_port: "22"
  - protocol: "icmp"
    inside_global_ip: "10.9.0.0"
    inside_global_port: "513"
    inside_local_ip: "10.1.0.2"
    inside_local_port: "512"
    outside_local_ip: "10.2.0.2"
    outside_local_port: "513"
    outside_global_ip: "10.2.0.2"
    outside_global_port: "512"
  - protocol: "---"
    inside_global_ip: "10.9.0.0"
    inside_global_port: ""
    inside_local_ip: "10.1.0.2"
    inside_local_port: ""
    outside_local_ip: "---"
    outside_local_port: ""
    outside_global_ip: "---"
    outside_global_port: ""

5. テスト

プッシュする前にローカルでテストを実施します。 実施しるためにtoxをインストールしておく必要があります。

pip install tox

テストの実行

tox

テストが開始され、いろいろとテストしてくれてますかっこいいですね(アホ)
f:id:usage_automate:20211203181225p:plain

すべてPASSになったことを確認します
f:id:usage_automate:20211203181426p:plain

これでテストは完了です、テスト実施に出来上がるキャッシュファイルや.toxはすべて削除しました。 (毎回手で削除する必要なさそうだけどよくわからず。)

この時、Lintも同時行ってくれます、テストが通るまで根気よく修正します。

6. コミット&プッシュ

git add .
git commit -m 'add show ip nat translations template'
git push origin add-cisco-show-ip-nat-translations

7. プルリクエスト作成

プッシュが完了したらGithubでプルリクエストを作成します、この時マージ先はntc-templates/masterになります。 github.com

プルリクエストを作成するとCIが走るようになっているようです。 この時、なぜ更新をかけていない箇所でCIのエラーが出てしまいました。
ローカルのテストでは引っかからなかったためプルリクエストのコメントでレビュアーに確認しました。

何やらmasterにバグがあったのか、再現性があったりなかったりで暫定的な対応をしてもらいいったん解決

バグの修正をしてもらい、コメントをしつつマージをされるのを待ちました。

8. マージされた

そして、マージされました。めでたしめでたし。

f:id:usage_automate:20211203184036p:plain

次回?のバージョンにも追加されるようです。

まとめ

フォークするところからマージまで一連の手順を書きました。
実際には何度もlintで引っかかったり、別のエラーがでたりでそこまでスムーズではなかったですが、流れがなんとなくでもつかんでもらえれば幸いです。

今年から始めたOSS活動ですが、少しづつ、ほんとに少しづつですができることが増えてきました。
来年は2桁マージを目指したいなと思います。

TTP(Text Template Paser)入門

やりたかったこと

前回、ntc-templateの自作に入門したので今回は同じ内容を別のパーサーで試してみます。
やることはshow ip nat translationsのパースです。

公式ドキュメント ttp.readthedocs.io

Github github.com

参考にさせていただいたブログ zaki-hmkc.hatenablog.com

準備

環境

python
* Python 3.9.6

ライブラリ
* ansible-core==2.12.0 * ttp==0.8.1

コレクション
* ansible.netcommon 2.4.0
* ansible.utils 2.4.2

実装

まずは公式ドキュメントのQuick startにあるスクリプト動かして確認を使い方を確認をします。

from ttp import ttp

data_to_parse = """
interface Loopback0
 description Router-id-loopback
 ip address 192.168.0.113/24
!
interface Vlan778
 description CPE_Acces_Vlan
 ip address 2002::fd37/124
 ip vrf CPE1
!
"""

ttp_template = """
interface {{ interface }}
 ip address {{ ip }}/{{ mask }}
 description {{ description }}
 ip vrf {{ vrf }}
"""

# create parser object and parse data using template:
parser = ttp(data=data_to_parse, template=ttp_template)
parser.parse()

# print result in JSON format
results = parser.result(format='json')[0]
print(results)

# or in csv format
csv_results = parser.result(format='csv')[0]
print(csv_results)

パース前のデータをdata_to_parseに格納、ttp_templateでパースをします。
result表示の際にformatの指定もできるようです。

実行してみます。

ttp# python sample.py 
# format JSON
[
    [
        {
            "description": "Router-id-loopback",
            "interface": "Loopback0",
            "ip": "192.168.0.113",
            "mask": "24"
        },
        {
            "description": "CPE_Acces_Vlan",
            "interface": "Vlan778",
            "ip": "2002::fd37",
            "mask": "124",
            "vrf": "CPE1"
        }
    ]
]

# format CSV
"description","interface","ip","mask","vrf"
"Router-id-loopback","Loopback0","192.168.0.113","24",""
"CPE_Acces_Vlan","Vlan778","2002::fd37","124","CPE1"

JSONCSVになったデータが表示されました。

テンプレート部分に注目をすると使い方のイメージがつくと思います。 出力結果を抜き出して変数として登録するようなテンプレートを作成します。

# Templateファイル
interface {{ interface }}
 ip address {{ ip }}/{{ mask }}
 description {{ description }}
 ip vrf {{ vrf }}

# パース前
interface Vlan778
 description CPE_Acces_Vlan
 ip address 2002::fd37/124
 ip vrf CPE1

# パース後
{
    "description": "CPE_Acces_Vlan",
    "interface": "Vlan778",
    "ip": "2002::fd37",
    "mask": "124",
    "vrf": "CPE1"
}

ある程度テンプレートも用意されているようです。 github.com

それでは他のテンプレートを参考にshow ip nat translationsのパーサーを作成します。 show ip nat translationsの結果は以下です。

Pro Inside global      Inside local       Outside local      Outside global
tcp 10.9.0.0:51776     10.1.0.2:51776     10.2.0.2:21        10.2.0.2:21
tcp 10.9.0.0:51778     10.1.0.2:51778     10.2.0.2:21        10.2.0.2:21
tcp 10.9.0.0:56384     10.1.0.2:56384     10.2.0.2:22        10.2.0.2:22
icmp 10.9.0.0:56111     10.1.0.2:56384     10.2.0.2:23        10.2.0.2:23
--- 10.9.0.0     10.1.0.2     ---        ---
--- 10.9.0.1     10.1.0.3     ---        ---

Pro以外の要素はipip:portのルールで表記されています。
IPのみで表記されているのがstatic natになる想定なので、テンプレートでマッチする条件を調整します。

# dynamic natのエントリー
tcp 10.9.0.0:51776     10.1.0.2:51776     10.2.0.2:21        10.2.0.2:21

# static natのエントリー
--- 10.9.0.0     10.1.0.2     ---        ---

作成したテンプレートが以下です。

<group name="dynamic_nat">
{{ protocol }} {{ inside_global_ip }}:{{ inside_global_port }} {{ inside_local_ip }}:{{ inside_local_port }} {{ outside_local_ip }}:{{ outside_local_port }} {{ outside_global_ip }}:{{ outside_global_port }}
</group>

<group name="static_nat">
{{ protocol }} {{ inside_global_ip | re("(\S+)(?!.*:)") }} {{ inside_local_ip | re("(\S+)(?!.*:)") }} {{ outside_local_ip | re("(\S+)(?!.*:)") }} {{ outside_global_ip | re("(\S+)(?!.*:)") }}
</group>

でくくることでdynamicとstaticを別のグループにしています。 static_natのグループでは:が表示結果に含まれていないことを条件にしました。

今回はanisbleでパースをします、templatesディレクトリにテンプレートファイルを配置します。

.
├── sample.yml
└── templates
    └── ios_show_ip_nat_translations.ttp <--作成したパーサー

ansible-playbookを作成します。 ansible.utiles.cli_parseを利用します。 tempalte_pathはなぜかtemplates配下を見に行ってくれなかったのでplaybookのある階層からtemplateファイルを指定しています。

---
- name: Test Template Text Parser
  hosts: localhost
  gather_facts: false

  tasks:
    - name: parse
      ansible.utils.cli_parse:
        text: |-
          Pro Inside global      Inside local       Outside local      Outside global
          tcp 10.9.0.0:51776     10.1.0.2:51776     10.2.0.2:21        10.2.0.2:21
          tcp 10.9.0.0:51778     10.1.0.2:51778     10.2.0.2:21        10.2.0.2:21
          tcp 10.9.0.0:56384     10.1.0.2:56384     10.2.0.2:22        10.2.0.2:22
          icmp 10.9.0.0:56111     10.1.0.2:56384     10.2.0.2:23        10.2.0.2:23
          --- 10.9.0.0     10.1.0.2     ---        ---
          --- 10.9.0.1     10.1.0.3     ---        ---
        parser:
          name: ansible.netcommon.ttp
          template_path: ./templates/ios_show_ip_nat_translations.ttp
      register: res_parsed

    - name: debug
      debug:
        msg: "{{ res_parsed.parsed[0][0] }}"

確認

ansible-playbookを実行して動作を確認します。

PLAY [Test Template Text Parser] *****************************************************************************************************************************

TASK [parse] *************************************************************************************************************************************************
[WARNING]: Use 'ansible.utils.ttp' for parser name instead of 'ansible.netcommon.ttp'. This feature will be removed from 'ansible.netcommon' collection in a
release after 2022-11-01
ok: [localhost]

TASK [debug] *************************************************************************************************************************************************
ok: [localhost] => {
    "msg": {
        "dynamic_nat": [
            {
                "inside_global_ip": "10.9.0.0",
                "inside_global_port": "51776",
                "inside_local_ip": "10.1.0.2",
                "inside_local_port": "51776",
                "outside_global_ip": "10.2.0.2",
                "outside_global_port": "21",
                "outside_local_ip": "10.2.0.2",
                "outside_local_port": "21",
                "protocol": "tcp"
            },
            {
                "inside_global_ip": "10.9.0.0",
                "inside_global_port": "51778",
                "inside_local_ip": "10.1.0.2",
                "inside_local_port": "51778",
                "outside_global_ip": "10.2.0.2",
                "outside_global_port": "21",
                "outside_local_ip": "10.2.0.2",
                "outside_local_port": "21",
                "protocol": "tcp"
            },
            {
                "inside_global_ip": "10.9.0.0",
                "inside_global_port": "56384",
                "inside_local_ip": "10.1.0.2",
                "inside_local_port": "56384",
                "outside_global_ip": "10.2.0.2",
                "outside_global_port": "22",
                "outside_local_ip": "10.2.0.2",
                "outside_local_port": "22",
                "protocol": "tcp"
            },
            {
                "inside_global_ip": "10.9.0.0",
                "inside_global_port": "56111",
                "inside_local_ip": "10.1.0.2",
                "inside_local_port": "56384",
                "outside_global_ip": "10.2.0.2",
                "outside_global_port": "23",
                "outside_local_ip": "10.2.0.2",
                "outside_local_port": "23",
                "protocol": "icmp"
            }
        ],
        "static_nat": [
            {
                "inside_global_ip": "10.9.0.0",
                "inside_local_ip": "10.1.0.2",
                "outside_global_ip": "---",
                "outside_local_ip": "---",
                "protocol": "---"
            },
            {
                "inside_global_ip": "10.9.0.1",
                "inside_local_ip": "10.1.0.3",
                "outside_global_ip": "---",
                "outside_local_ip": "---",
                "protocol": "---"
            }
        ]
    }
}

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

パースされ、想定通りの結果を得ることができました。 dynamic_natグループはポートの情報も分けて取得することができています。

パース後の要素が2個以上ある場合は自動的にリストになるようです。要素が1個の場合リストにならないため、パース後に呼び出す際に注意が必要です。
テンプレートの書き方を調整すれば常にリストになるようにできるかもしれません。(※)

(※) 2021/12/16更新 解決方法が分かりました usage-automate.hatenablog.com

パース後の結果がけっこう深い階層にあったのがよくわからなかったですが、動作としては問題なかったです。res_parsed.parsed[0][0]

まとめ

TTP入門してみました。テンプレートがとても簡単に作れることができ、かなり便利です。
テンプレート自作ができるようになるとCLIの機器自動化でできることがかなり増えます、よければ参考にしてください。