zaki work log

作業ログやら生活ログやらなんやら

[Ansible] cli_parseとTTP(Template Text Parser)を使った任意のテキストのparse処理おためし入門

Ansibleのcli_parseモジュールで、PythonのTTP(Template Text Parser)とテンプレートファイルを使ったテキスト解析を試してみた。

ttp.readthedocs.io

cli_parseは、2.10時点で標準のansible.netcommon.cli_parseと、Ansible 3(3.0.0b1)に新たに標準に含まれているansible.utils.cli_parseがある。
現状では中身は同じだが、ansible.utilsコレクション側でメンテされる(こっちに移管される?)のかな?

今回はひとまず標準で使用可能なansible.netcommon.cli_parseを使ってみる。
ansible.utils.cli_parseの場合はnetcommonの場所を読み替えればOK。(のハズ)

環境

  • Ansible: 2.10.5
  • ansible.netcommon 1.4.1
  • ttp: 0.6.0

お題

ip aの結果をparseしてみる。

1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host 
       valid_lft forever preferred_lft forever
2: ens192: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 1000
    link/ether 00:0c:29:22:86:2f brd ff:ff:ff:ff:ff:ff
    inet 192.168.0.18/24 brd 192.168.0.255 scope global noprefixroute ens192
       valid_lft forever preferred_lft forever
    inet6 fe80::20c:29ff:fe22:862f/64 scope link 
       valid_lft forever preferred_lft forever
3: br-7a62608ea9c3: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default 
    link/ether 02:42:78:cd:77:68 brd ff:ff:ff:ff:ff:ff
    inet 172.18.0.1/16 brd 172.18.255.255 scope global br-7a62608ea9c3
       valid_lft forever preferred_lft forever
4: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default 
    link/ether 02:42:12:db:d5:fd brd ff:ff:ff:ff:ff:ff
    inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0
       valid_lft forever preferred_lft forever
    inet6 fe80::42:12ff:fedb:d5fd/64 scope link 
       valid_lft forever preferred_lft forever
5: br-ec4e8609788c: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default 
    link/ether 02:42:a0:58:03:b3 brd ff:ff:ff:ff:ff:ff
    inet 172.26.0.1/16 brd 172.26.255.255 scope global br-ec4e8609788c
       valid_lft forever preferred_lft forever
6: br-f28d1c1cb87b: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default 
    link/ether 02:42:03:19:f1:f8 brd ff:ff:ff:ff:ff:ff
    inet 172.19.0.1/16 brd 172.19.255.255 scope global br-f28d1c1cb87b
       valid_lft forever preferred_lft forever
7: br-11a5b9bc6a2c: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default 
    link/ether 02:42:aa:9c:c7:a8 brd ff:ff:ff:ff:ff:ff
    inet 172.20.0.1/16 brd 172.20.255.255 scope global br-11a5b9bc6a2c
       valid_lft forever preferred_lft forever
    inet6 fc00:f853:ccd:e793::1/64 scope global 
       valid_lft forever preferred_lft forever
    inet6 fe80::42:aaff:fe9c:c7a8/64 scope link 
       valid_lft forever preferred_lft forever
    inet6 fe80::1/64 scope link 
       valid_lft forever preferred_lft forever
8: br-1e7a6f36bebc: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default 
    link/ether 02:42:5f:6e:38:28 brd ff:ff:ff:ff:ff:ff
    inet 172.21.0.1/16 brd 172.21.255.255 scope global br-1e7a6f36bebc
       valid_lft forever preferred_lft forever
9: br-4221181b1c51: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default 
    link/ether 02:42:de:a0:d1:67 brd ff:ff:ff:ff:ff:ff
    inet 172.22.0.1/16 brd 172.22.255.255 scope global br-4221181b1c51
       valid_lft forever preferred_lft forever
44: br-d51584ee1064: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default 
    link/ether 02:42:83:4a:1d:18 brd ff:ff:ff:ff:ff:ff
    inet 172.23.0.1/16 brd 172.23.255.255 scope global br-d51584ee1064
       valid_lft forever preferred_lft forever
    inet6 fe80::42:83ff:fe4a:1d18/64 scope link 
       valid_lft forever preferred_lft forever
71: br-378c264dc6ee: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default 
    link/ether 02:42:21:be:06:4f brd ff:ff:ff:ff:ff:ff
    inet 172.24.0.1/16 brd 172.24.255.255 scope global br-378c264dc6ee
       valid_lft forever preferred_lft forever
    inet6 fe80::42:21ff:febe:64f/64 scope link 
       valid_lft forever preferred_lft forever
117: vethc853031@if116: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP group default 
    link/ether ea:d7:90:4c:62:be brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet6 fe80::e8d7:90ff:fe4c:62be/64 scope link 
       valid_lft forever preferred_lft forever

準備

ttp

TTPの利用にはPythonモジュールのttpが必要。
無いとエラーになる。

TASK [cli parse] **************************************************************
fatal: [localhost]: FAILED! => changed=false 
  msg: Failed to import the required Python library (ttp) on cloud-dev's Python /home/zaki/src/ansible-sample/venv/a2.10/bin/python3. Please read the module documentation and install it in the appropriate location. If the required library is installed, but Ansible is using the wrong Python interpreter, please consult the documentation on ansible_python_interpreter

$ pip install ttpする。

ansible.utils

ansible.netcommon.cli_parseでなくansible.utils.cli_parseを使用する場合は追加でコレクションをインストールする。

$ ansible-galaxy collection install ansible.utils

pip install ansibleでインストールしたAnsible環境であればansible.netcommon.cli_parseは標準で利用可能。

cli_parse

入力テキスト

cli_parseでテキストをparseする上で、よくあるケースとしては「ネットワークコマンドの実行結果のparse」で、これはcommandパラメタでコマンドを指定することで実現できる。
今回のip aについても同様にcommandで指定する。

    - name: cli parse
      ansible.netcommon.cli_parse:
        command: ip a

なお、すでにコマンド結果をテキストで保持しており、その変数を対象にparseしたい場合はtextで指定することもできる。

      ansible.netcommon.cli_parse:
        text: "{{ ip_a_result }}"

TTPの指定

parseのエンジンにTTPを指定する。
それ加えて、parse用のテンプレートファイルを指定する。

        parser:
          name: ansible.netcommon.ttp
          template_path: "./ttp-template/ip-a.ttp"

ここでは./ttp-template/ip-a.ttpにあるファイルを指定。

TTPテンプレートファイル

ip aの結果に対して何をピックアップしたいかをテンプレートファイルに定義する。

{{ number }}: {{ interface }}: {{ mode }} mtu {{ mtu }} {{ ignore(".*") }}
    inet {{ addr }} brd {{ broadcast }} {{ ignore(".*") }}

このテンプレートを使うことで、ip aの結果の以下の部分をparseすることができる。

2: ens192: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 ...
    inet 192.168.0.18/24 brd 192.168.0.255 ...

parseした結果は、{{ hogehoge }}で定義した箇所をhogehoge: 元テキストの値という構造化データとして扱うことができる。

ignore()

ttp.readthedocs.io

テンプレートファイルの定義は先頭一致ではなく行全体をマッチングするため、取り込みたい部分以降についても記載が必要。
その際、特に処理が不要であればignoreを指定すれば無視される。

ignoreはデフォルトで正規表現\Sにマッチでスペースは含まれないため、「行の残り全てを無視」であれば、正規表現.*を指定する。

サンプル実行

    - name: cli parse
      ansible.netcommon.cli_parse:
        command: ip a
        parser:
          name: ansible.netcommon.ttp
          template_path: "./ttp-template/ip-a.ttp"
      register: result
    - debug: print result
      debug:
        msg: "{{ result.parsed }}"

registerを使って結果を参照している。
これを実行すると以下の通り。

ok: [localhost] => 
  msg:
  - - - interface: lo
        mode: <LOOPBACK,UP,LOWER_UP>
        mtu: '65536'
        number: '1'
      - addr: 192.168.0.18/24
        broadcast: 192.168.0.255
        interface: ens192
        mode: <BROADCAST,MULTICAST,UP,LOWER_UP>
        mtu: '1500'
        number: '2'
      - addr: 172.18.0.1/16
        broadcast: 172.18.255.255
        interface: br-7a62608ea9c3
        mode: <NO-CARRIER,BROADCAST,MULTICAST,UP>
        mtu: '1500'
        number: '3'
      - addr: 172.17.0.1/16
        broadcast: 172.17.255.255
        interface: docker0
        mode: <BROADCAST,MULTICAST,UP,LOWER_UP>
        mtu: '1500'
        number: '4'
      - addr: 172.26.0.1/16
        broadcast: 172.26.255.255
        interface: br-ec4e8609788c
        mode: <NO-CARRIER,BROADCAST,MULTICAST,UP>
        mtu: '1500'
        number: '5'
      - addr: 172.19.0.1/16
        broadcast: 172.19.255.255
        interface: br-f28d1c1cb87b
        mode: <NO-CARRIER,BROADCAST,MULTICAST,UP>
        mtu: '1500'
        number: '6'
      - addr: 172.20.0.1/16
        broadcast: 172.20.255.255
        interface: br-11a5b9bc6a2c
        mode: <NO-CARRIER,BROADCAST,MULTICAST,UP>
        mtu: '1500'
        number: '7'
      - addr: 172.21.0.1/16
        broadcast: 172.21.255.255
        interface: br-1e7a6f36bebc
        mode: <NO-CARRIER,BROADCAST,MULTICAST,UP>
        mtu: '1500'
        number: '8'
      - addr: 172.22.0.1/16
        broadcast: 172.22.255.255
        interface: br-4221181b1c51
        mode: <NO-CARRIER,BROADCAST,MULTICAST,UP>
        mtu: '1500'
        number: '9'
      - addr: 172.23.0.1/16
        broadcast: 172.23.255.255
        interface: br-d51584ee1064
        mode: <NO-CARRIER,BROADCAST,MULTICAST,UP>
        mtu: '1500'
        number: '44'
      - addr: 172.24.0.1/16
        broadcast: 172.24.255.255
        interface: br-378c264dc6ee
        mode: <NO-CARRIER,BROADCAST,MULTICAST,UP>
        mtu: '1500'
        number: '71'
      - interface: vethc853031@if116
        mode: <BROADCAST,MULTICAST,UP,LOWER_UP>
        mtu: '1500'
        number: '117'

何となくいい感じに構造化データにできた。

テンプレートの書き方いろいろ

行内の項目が有ったり無かったり

例えばinetの行、ループバック(lo)の場合は以下の通り。

    inet 127.0.0.1/8 scope host lo

対して、ループバック以外のインタフェースについては以下の通り。

    inet 192.168.0.18/24 brd 192.168.0.255 scope global noprefixroute ens192

この通り、brd ブロードキャストアドレスの項目が有ったり無かったりする。

この場合は、無理に1行でテンプレートを定義せずに(というかわからなかった)、2行に分けると簡単に定義できる。

{{ number }}: {{ interface }}: {{ mode }} mtu {{ mtu }} {{ ignore(".*") }}
    inet {{ addr }} brd {{ broadcast }} {{ ignore(".*") }}
    inet {{ addr }} {{ ignore(".*") }}

このテンプレートを使用すれば、parse結果は以下の通り。

ok: [localhost] => 
  msg:
  - - - addr: 127.0.0.1/8
        interface: lo
        mode: <LOOPBACK,UP,LOWER_UP>
        mtu: '65536'
        number: '1'
      - addr: 192.168.0.18/24
        broadcast: 192.168.0.255
        interface: ens192
        mode: <BROADCAST,MULTICAST,UP,LOWER_UP>
        mtu: '1500'
        number: '2'
      - addr: 172.18.0.1/16
        broadcast: 172.18.255.255
        interface: br-7a62608ea9c3
        mode: <NO-CARRIER,BROADCAST,MULTICAST,UP>
        mtu: '1500'
        number: '3'

        [...snip...]

この通り、ブロードキャストアドレスの記載の無いループバックのインタフェースも、inetの行のIPアドレスを取得できている。

重要なのはbrd ブロードキャストアドレスのテンプレートから先に記述すること。
ブロードキャストアドレスの定義がない行を先に書いてしまうと、全ての項目がこちらにマッチしてしまい、ブロードキャストアドレスのテンプレートまで処理が到達しない。

ただし、先頭行はこの方法は使えないので別の対処が必要。

先頭行の複数定義

テンプレートの先頭行を、

{{ number }}: {{ interface }}: {{ mode }} mtu {{ mtu }} qdisc {{ qdisc }} state {{ state }} group {{ group }} qlen {{ qlen }}

と記述すると、ループバックとens192はマッチするが、残りのインタフェースはqlenの記載がないためそもそも処理対象にならない。
実行すると以下の通り。

ok: [localhost] => 
  msg:
  - - - addr: 127.0.0.1/8
        group: default
        interface: lo
        mode: <LOOPBACK,UP,LOWER_UP>
        mtu: '65536'
        number: '1'
        qdisc: noqueue
        qlen: '1000'
        state: UNKNOWN
      - addr: 192.168.0.18/24
        broadcast: 192.168.0.255
        group: default
        interface: ens192
        mode: <BROADCAST,MULTICAST,UP,LOWER_UP>
        mtu: '1500'
        number: '2'
        qdisc: mq
        qlen: '1000'
        state: UP

そしてこの場合、前述と同じように以下のように2行安直に書いても動作しない。(2行目の記述が機能しない)

※ ダメな例
{{ number }}: {{ interface }}: {{ mode }} mtu {{ mtu }} qdisc {{ qdisc }} state {{ state }} group {{ group }} qlen {{ qlen }}
{{ number }}: {{ interface }}: {{ mode }} mtu {{ mtu }} qdisc {{ qdisc }} state {{ state }} group {{ group }}
    inet {{ addr }} brd {{ broadcast }} {{ ignore(".*") }}
    inet {{ addr }} {{ ignore(".*") }}

これは、TTPのテンプレートにおける1行目の記述は「グループの開始」を表す意味も持っており、デフォルトでは1行目のみがその効果を持つ。
そこで、グループの開始の定義を複数行で行いたい場合は_start_識別子を使う。

{{ number }}: {{ interface }}: {{ mode }} mtu {{ mtu }} qdisc {{ qdisc }} state {{ state }} group {{ group }} qlen {{ qlen }}
{{ number | _start_ }}: {{ interface }}: {{ mode }} mtu {{ mtu }} qdisc {{ qdisc }} state {{ state }} group {{ group }}
    inet {{ addr }} brd {{ broadcast }} {{ ignore(".*") }}
    inet {{ addr }} {{ ignore(".*") }}

このように2行目の記述した定義に| _start_を追加(これは試した限り行内のどの変数に入れても大丈夫そう。ドキュメントへの記載は見当たらず)する。
このテンプレートで実行すると以下の通り、qlenの有るインタフェースも無いインタフェースもparseできている。

ok: [localhost] => 
  msg:
  - - - addr: 127.0.0.1/8
        group: default
        interface: lo
        mode: <LOOPBACK,UP,LOWER_UP>
        mtu: '65536'
        number: '1'
        qdisc: noqueue
        qlen: '1000'
        state: UNKNOWN
      - addr: 192.168.0.18/24
        broadcast: 192.168.0.255
        group: default
        interface: ens192
        mode: <BROADCAST,MULTICAST,UP,LOWER_UP>
        mtu: '1500'
        number: '2'
        qdisc: mq
        qlen: '1000'
        state: UP
      - addr: 172.18.0.1/16
        broadcast: 172.18.255.255
        group: default
        interface: br-7a62608ea9c3
        mode: <NO-CARRIER,BROADCAST,MULTICAST,UP>
        mtu: '1500'
        number: '3'
        qdisc: noqueue
        state: DOWN
      - addr: 172.17.0.1/16
        broadcast: 172.17.255.255
        group: default
        interface: docker0
        mode: <BROADCAST,MULTICAST,UP,LOWER_UP>
        mtu: '1500'
        number: '4'
        qdisc: noqueue
        state: UP

        [...snip...]

<>

{{ mode }}で取っている mode: <BROADCAST,MULTICAST,UP,LOWER_UP> の部分、普通に考えて<>を取り除きたいけど、単純に

{{ number }}: {{ interface }}: <{{ mode }}> mtu {{ mtu }} ...

のようにテンプレート内に<>を直接記述すると、

fatal: [localhost]: FAILED! => changed=false 
  msg: 'Template Text Parser returned an error while parsing. Error: not well-formed (invalid token): line 2, column 32'

とTTPの書式のエラーになってしまう。
これは、TTPのテンプレート内ではXML書式として<>がメタ文字として扱われるためで、たとえば1テンプレートファイルで複数の書式を記述したい場合に

<group name="interfaces">
{{ number }}: {{ interface }}: {{ mode }} ...
    inet {{ addr }} brd {{ broadcast }} {{ ignore(".*") }}
    inet {{ addr }} {{ ignore(".*") }}
</group>

このような使い方をするため、TTPの処理で<>が期待する使われ方をしていないというエラーになっている。

回避策としてはHTMLの実体参照で記載する(これ見つけるのに半日かかった)。。。んだけど、これバグじゃね?って感じが少しするが、とりあえず現バージョン(ansible.netcommon 1.4.1 + ttp 0.6.0)だと以下の通りの動作。

ただし、HTML実体参照で動くというのもドキュメントには記載を見つけられなかったので、正しい使い方なのか未対応の処理を貫通した動作なのかは不明。

<group>で囲む

全体を<group name="...">などで囲む。

ttp.readthedocs.io

その上で、以下のように&lt;{{ mode }}&gt;と記載する。

<group name="interfaces">
{{ number }}: {{ interface }}: &lt;{{ mode }}&gt; mtu {{ mtu }} qdisc {{ qdisc }} state {{ state }} group {{ group }} qlen {{ qlen }}
{{ number | _start_ }}: {{ interface }}: &lt;{{ mode }}&gt; mtu {{ mtu }} qdisc {{ qdisc }} state {{ state }} group {{ group }}
    inet {{ addr }} brd {{ broadcast }} {{ ignore(".*") }}
    inet {{ addr }} {{ ignore(".*") }}
</group>

そうすると、結果の構造がnameで指定したキーの辞書型になるが、HTMLの実体参照を使って&lt;&gt;を指定したものが<>にマッチして正しく動く。

結果は以下の通り。

ok: [localhost] => 
  msg:
  - - interfaces:
      - addr: 127.0.0.1/8
        group: default
        interface: lo
        mode: LOOPBACK,UP,LOWER_UP
        mtu: '65536'
        number: '1'
        qdisc: noqueue
        qlen: '1000'
        state: UNKNOWN
      - addr: 192.168.0.18/24
        broadcast: 192.168.0.255
        group: default
        interface: ens192
        mode: BROADCAST,MULTICAST,UP,LOWER_UP
        mtu: '1500'
        number: '2'
        qdisc: mq
        qlen: '1000'
        state: UP
      - addr: 172.18.0.1/16
        broadcast: 172.18.255.255
        group: default
        interface: br-7a62608ea9c3
        mode: NO-CARRIER,BROADCAST,MULTICAST,UP
        mtu: '1500'
        number: '3'
        qdisc: noqueue
        state: DOWN

        [...snip...]

直接HTML実体参照を記述

以下のように<group>などで囲まずに直接HTML実体参照を記述すると、

※ ダメな例
{{ number }}: {{ interface }}: &lt;{{ mode }}&gt; mtu {{ mtu }} qdisc {{ qdisc }} state {{ state }} group {{ group }} qlen {{ qlen }}
{{ number | _start_ }}: {{ interface }}: &lt;{{ mode }}&gt; mtu {{ mtu }} qdisc {{ qdisc }} state {{ state }} group {{ group }}
    inet {{ addr }} brd {{ broadcast }} {{ ignore(".*") }}
    inet {{ addr }} {{ ignore(".*") }}

<>を直接使用した場合と同じくinvalid tokenのエラーとなる。
ここでちょっと小細工して、&をさらに実体参照で記述して、

{{ number }}: {{ interface }}: &amp;lt;{{ mode }}&gt; ...

と記述すると正しく動作する。
TTPの処理のソース見ればわかるかもしれないけどそこまではチェックしてないが、どうやら&を見つけたところから処理が変わってると思われ。
ちなみに&gt;の方はそのままでも動作するし、試した感じでは以下のように>と直接書いても大丈夫だった。

{{ number }}: {{ interface }}: &amp;lt;{{ mode }}> ...

紛らわしいので、現状では<group>で囲んだ方が良さそう。

部分的にsplit

<>を除いたらこの内部の,区切りの文字列をバラシてリストにしたいですが、はい、splitも利用可。

<group name="interfaces">
{{ number }}: {{ interface }}: &lt;{{ mode | split(",") }}&gt; mtu {{ mtu }} qdisc {{ qdisc }} state {{ state }} group {{ group }} qlen {{ qlen }}
{{ number | _start_ }}: {{ interface }}: &lt;{{ mode | split(",") }}&gt; mtu {{ mtu }} qdisc {{ qdisc }} state {{ state }} group {{ group }}
    inet {{ addr }} brd {{ broadcast }} {{ ignore(".*") }}
    inet {{ addr }} {{ ignore(".*") }}
</group>

これ実行すれば以下の通り。

ok: [localhost] => 
  msg:
  - - interfaces:
      - addr: 127.0.0.1/8
        group: default
        interface: lo
        mode:
        - LOOPBACK
        - UP
        - LOWER_UP
        mtu: '65536'
        number: '1'
        qdisc: noqueue
        qlen: '1000'
        state: UNKNOWN
      - addr: 192.168.0.18/24
        broadcast: 192.168.0.255
        group: default
        interface: ens192
        mode:
        - BROADCAST
        - MULTICAST
        - UP
        - LOWER_UP
        mtu: '1500'
        number: '2'
        qdisc: mq
        qlen: '1000'
        state: UP
      - addr: 172.18.0.1/16
        broadcast: 172.18.255.255
        group: default
        interface: br-7a62608ea9c3
        mode:
        - NO-CARRIER
        - BROADCAST
        - MULTICAST
        - UP
        mtu: '1500'
        number: '3'
        qdisc: noqueue
        state: DOWN

        [...snip...]

splitはドキュメントに記載がない。
joinはあるのに何故だろう。。

まとめ

ということで、自前のテンプレートを用意することで、Ansibleのcli_parseとTTPを使って任意の書式の解析と構造化データとしての取り込みはできそう。
なお、cli_parseはTTP以外にもtextfsmなどほかのparserを使用することができるので、そのうち試したい。

関連ドキュメント

netcommon.cli_parseのドキュメントはこちら。

docs.ansible.com

コレクションのGitHubプロジェクトのドキュメントはこちら。

github.com

github.com

構造化でデータのparseについて

docs.ansible.com

TTP(Template Text Parser)

ttp.readthedocs.io

てくなべcli_parse

tekunabe.hatenablog.jp

サンプルコード

github.com