zaki work log

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

[Ansible / Jinja2] select / selectattr を使った配列と辞書のフィルタリング

配列や、辞書の配列の中から特定の条件の要素を抜き出す。
配列操作はselect()で要素に対してフィルター処理を行い、辞書操作はselectattr()でkey指定することでvalueにフィルター処理を行う。

ここでいうフィルター処理は、AnsibleやJinja2のフィルターのことではなく、AnsibleやJinja2のTest(被演算子/operatorと言うと個人的にはわかりやすいんだけど、「被演算子」という表記は特に無く、Jinja2だとisを使った被演算子の方で関数的に使用)を使って要素をフィルタリングすることを指す。

Ansibleで変数名 is HogeHoge(...)という書式で使われているHogeHoge()の部分。 これをselect()selectattr()で使って配列・辞書に対してまとめて処理可能になっている (はず)。

select()selectattr()は、どちらもJinja2テンプレートのサイトにBuiltin Filtersとして記載があるのでそちらを参照。

jinja.palletsprojects.com

この記事では、以下のバージョンを前提としている。

  • Ansible: 2.10.5
  • Python: 3.6.8
  • Jinja2: 2.11.2
  • OS: CentOS 7.9.2009

配列 (select)

指定したTestの演算結果がtrueの場合にその要素を返す。
って書くとなんか分かりづらいけど、要は条件に合致した配列要素の場合のみ、その要素を返す。

数値 (一致・大小)

比較演算子が使える。

  vars:
    integer_values:
      - 0
      - 1
      - 2
      - 3
      - 4

  - name: select integer values
    debug:
      msg:
      - '{{ integer_values | select("eq", 2) }}'  # 2と同値か  => [2]
      - '{{ integer_values | select("==", 2) }}'  # "eq"と同じ
      - '{{ integer_values | select("le", 3) }}'  # 3以下か    => [0,1,2,3]
      - '{{ integer_values | select("<=", 3) }}'  # "le"と同じ
      - '{{ integer_values | select("lt", 3) }}'  # 3未満か    => [0,1,2]
      - '{{ integer_values | select("<", 3) }}'   # "lt"と同じ

文字列 (一致・大小)

数値の場合と同様、eqまたは==で一致した場合。
(intは==、strはeqみたいに、どっちかに統一して欲しかったな、これ。。)

  vars:
    hosts:
      - web1
      - web2
      - web3
      - db1
      - db2

  tasks:
  - name: split filter sample
    debug:
      msg:
      - '{{ hosts | select("eq", "web1") }}'  # web1が取れる

文字列に対してはあまり使う頻度は無いと思うが、gt/>, ge/>=, lt/<, le/<= も使える。

正規表現

matchsearchを使った正規表現も使える。
正規表現はJinja2のTestでなく、Ansibleで用意されている。
処理内容はPythonPattern#match()Pattern#search()となっている。

Testing strings

  vars:
    hosts:
      - web1
      - web2
      - web3
      - db1
      - db2
      - web1
      - dns

  tasks:
  - name: split filter sample
    debug:
      msg:
      - '{{ hosts | select("match", "web") }}' # webにmatchするもの
      - '{{ hosts | select("match", "b") }}'   # bが含まれるもの…と思いきやre.match("b")っぽい。bで始まるもののみ
      - '{{ hosts | select("search", "b") }}'  # bがどこかに含まれるもの re.search("b")っぽい
      - '{{ hosts | select("regex", "b") }}'   # デフォルトでは"search"と同じように動作。

また、regexもあり、これはmatch_typeという名前付き引数を指定することで、Pythonのreで使用するメソッドを指定できる。

'{{ hosts | select("regex", "d.*\d+", match_type="fullmatch") }}'   # match_typeでメソッド指定

docs.python.org

型でフィルタリング

配列要素として数値や文字列・booleanなどバラバラの型の値が入っている場合に、型でフィルタリングもできる。
型名の文字列のみを引数に指定する。

    type_mix:
      - 1
      - 12
      - a
      - ab
      - curry
      - 755
      - true
      - false
      - 3.14
      - "true"
      - "false"
      - "123"
      - "3.14"
      - - subitem1
        - subitem2
      - key: 1
        value: 2

- name: select values type
    debug:
      msg:
      - '{{ type_mix | select("string") }}'   # curry, 'trye', ...
      - '{{ type_mix | select("integer") }}'  # 755
      - '{{ type_mix | select("number") }}'   # 755, true, false, 3.14
      - '{{ type_mix | select("boolean") }}'  # true, false
      - '{{ type_mix | select("float") }}'    # 3.14
      - '{{ type_mix | select("sequence") }}' # リストだけではなく、文字列・リスト・辞書が取れる
      - '{{ type_mix | select("mapping") }}'  # {key, value}

sequenceのみ思ってたのと違った。。
(iterateとして処理できるとtrueになるため、辞書だけでなく文字列もヒットする)

また、numberはboolean値もヒットするので注意。
型というより、数値として処理できる変数であれば対象という感じ?(未確認)

ファイルパス

実行ホスト上に指定されたパスが存在するかどうかでフィルタリングできる。

Testing paths

  - name: file path test
    vars:
      path_list:
        - /
        - /bin
        - /usr/bin/perl
        - /usr/bin/python3
        - /not-found-path
    debug:
      msg:
      - '{{ path_list | select("directory") }}'  # /, /bin
      - '{{ path_list | select("file") }}'       # perl, python3
      - '{{ path_list | select("link") }}'       # /bin, python3
      - '{{ path_list | select("exists") }}'     # /not-found-path 以外
      - '{{ path_list | select("mount") }}'      # /

バージョン文字列

「〇〇が未インストール、または、インストール済みでバージョン□□以下の場合」みたいなことができる。

Comparing versions

  - name: version sample
    vars:
      version_string:
        - 1.0.0
        - 1.0.99999999999999999
        - 1.1.0
        - 1.1.1
        - 1.01.0
        - 1.01.1
        - 1.9.0
        - 1.10.0
        - '1.0.1'
        - '1.1.1'
        - '1.1'
    debug:
      msg:
      - '{{ version_string | select("version", "1.1.0", "gt") }}'  # ['1.1.1', '1.01.1', '1.9.0', '1.10.0', '1.1.1']

1.1.0との比較で、1.a.0を入力するとエラーになる。

fatal: [localhost]: FAILED! => 
  msg: 'Version comparison: ''<'' not supported between instances of ''str'' and ''int'''

versonについてはPythonのドキュメントに2021.02.16時点で記載がなぜか抜けているため、詳細はソースを見るしかない?

docs.python.org

StrictVersionにすると、<数字>.<数字>のような形式のバージョン文字列で無いとエラーになる。
デフォルトのLooseVersionであればもう少しユルい書式でバージョン比較できる。

Ansibleで実装されているversoin() / version_compare()についてはこちら
また、テスト(Test Pluginでなくソフトウェアテストの方のテスト)の内容についてはこちら

PythonVersion()は別途もうちょっと調べたいところ。。

含む (in)

Pythonin演算子と同じで、別途用意したリスト内に含まれる要素かどうかを、入力としてのリストの全要素に対して検査する。

  vars:
    hosts:
      - web1
      - web2
      - web3
      - db1
      - db2
      - dns
    contain_list:
      - db1
      - web1
      - mail1

  tasks:
  - name: contain list
    debug:
      msg: '{{ hosts | select("in", contain_list) }}' # ['web1', 'db1']

inと似たものにcontainsもあるが、配列に対するselectフィルターに使用する場合、containsの引数に一致する要素をフィルタリング…しそうだけどそうはならず、配列要素を更にリストへ分解してその要素に含まれていればtrueという判定となっている。(書いててよくわからん)
まぁ一致する要素を抜き出したければeqを使えばいいので配列要素に対するcontainsはナンセンスのような気もする。(使い方の認識を誤ってる可能性もあるけど…)

下記の場合、"b"を含む文字列であればtrueになる。

  vars:
    contain_list:
      - db1
      - web1
      - mail1

  tasks:
  - name: contain list
    debug:
      msg:
      - '{{ contain_list | select("contains", "b") }}'  # ['db1', 'web1']

配列要素がiterableである必要があるため、数値の配列だと型エラーとなる。

動作する例としては以下のような感じ。(見やすいようにインラインリストにしている)

  vars:
    list_in_list:
      - [1,2,3]
      - [4,5,6]
      - [7,8,9]
  tasks:
  - name: contain list
    debug:
      msg:
      - '{{ list_in_list | select("contains", 5) }}'  # '[4,5,6]

辞書 (selectattr)

辞書の配列(辞書型の変数を要素に持つ配列)に対するselectattr()は、「辞書のkey名とTest名を指定することで、そのvalueがtrueかどうか」という動作をする。
指定のTestを行い、結果がtrueの場合に要素を返すという機能自体はselect()と同じ。

配列と若干異なる点としては「指定されたkeyが存在しない場合」に備えてdefinedを使ってガード処理を行ったり、配列の場合の動作がいまいちだったcontainsを使った「配列を要素として持つ場合に、その配列中に指定の要素があるか」などがある。

Test例は配列で一通りやってるので簡単に。。
以下のような、IPアドレスやパッケージ情報のホスト情報っぽい辞書型変数を要素に持つ配列を対象にselectattr()を使ったフィルタリングを行ってみる。

- hosts: localhost
  gather_facts: no

  vars:
    hosts:
      - name: web1
        addr: 192.168.2.10
        mask: 255.255.255.0
        pkg:
          - httpd
          - php
          - python
      - name: web2
        addr: 192.168.2.11
        mask: 255.255.255.0
        pkg:
          - httpd
          - ruby
          - python
      - addr: 192.168.2.0
        mask: 255.255.255.0
        pkg:
          - php
      - name: db1
        addr: 192.168.2.20
        mask: 255.255.255.0
        pkg:
          - mysql
          - postgres
          - php

  tasks:
  - name: selectattr filter sample
    debug:
      msg:
      - '{{ hosts | selectattr("name", "defined") }}'
      - '{{ hosts | selectattr("name", "defined") | selectattr("name", "eq", "web1") }}'
      - '{{ hosts | selectattr("name", "defined") | selectattr("name", "match", ".*1") }}'
      - '{{ hosts | selectattr("pkg", "contains", "php") }}'

キーが存在するか

'{{ hosts | selectattr("name", "defined") }}'

definedで、指定のkeyが存在する要素のみを返す。
前述hostsの例であれば、結果は以下の通り。

ok: [localhost] => 
  msg:
  - - addr: 192.168.2.10
      mask: 255.255.255.0
      name: web1
      pkg:
      - httpd
      - php
      - python
    - addr: 192.168.2.11
      mask: 255.255.255.0
      name: web2
      pkg:
      - httpd
      - ruby
      - python
    - addr: 192.168.2.20
      mask: 255.255.255.0
      name: db1
      pkg:
      - mysql
      - postgres
      - php

キーnameの値との一致

'{{ hosts | selectattr("name", "defined") | selectattr("name", "eq", "web1") }}'

前述のdefinednameが存在するもののみをフィルタリングした結果に対し、更にnameの値がweb1のものを返す。

ok: [localhost] => 
  msg:
  :
  :
  - - addr: 192.168.2.10
      mask: 255.255.255.0
      name: web1
      pkg:
      - httpd
      - php
      - python

値に対する正規表現

'{{ hosts | selectattr("name", "defined") | selectattr("name", "match", ".*1") }}'

配列の場合と同様、matchsearchを使った正規表現も可能。
結果は以下の通り。

ok: [localhost] => 
  msg:
  :
  :
  - - addr: 192.168.2.10
      mask: 255.255.255.0
      name: web1
      pkg:
      - httpd
      - php
      - python
    - addr: 192.168.2.20
      mask: 255.255.255.0
      name: db1
      pkg:
      - mysql
      - postgres
      - php

含む (contains)

'{{ hosts | selectattr("pkg", "contains", "php") }}'

指定keyの値がリスト形式の場合、その配列要素に指定した文字列や数値を含む場合の要素を返す。
例の場合、valueに配列を持つkey名pkgに対して、その配列要素にphpを含む場合のホスト情報がフィルタリングされる。

ok: [localhost] => 
  msg:
  :
  :
  - - addr: 192.168.2.10
      mask: 255.255.255.0
      name: web1
      pkg:
      - httpd
      - php
      - python
    - addr: 192.168.2.0
      mask: 255.255.255.0
      pkg:
      - php
    - addr: 192.168.2.20
      mask: 255.255.255.0
      name: db1
      pkg:
      - mysql
      - postgres
      - php

含む (in)

(2021.07.15追記)
指定keyの値が文字列や数値などのスカラー値(ってAnsibleやPythonの文脈でいうの?)の場合で、比較対象として指定リストにその要素を含むか、という場合はinが使える。

addrチェック用に、次のようなリストを準備して、

    contain_list:
      - 192.168.2.20
      - 192.168.2.10
      - 192.168.0.89

以下の条件でフィルタを行うと、

'{{ hosts | selectattr("addr", "in", contain_list) }}'

このように、addrの値がcontain_listのリスト内に存在する値のみをフィルタリングできる。

ok: [localhost] => 
  msg:
  - - addr: 192.168.2.10
      mask: 255.255.255.0
      name: web1
      pkg:
      - httpd
      - php
      - python
    - addr: 192.168.2.20
      mask: 255.255.255.0
      name: db1
      pkg:
      - mysql
      - postgres
      - php

以下過去の記事↓
入力が多重リストになってた。この比較はNGっぽい

前述のselect()の例で使用したinは、使い方が悪いのか空リストになった。

  - name: selectattr filter sample
    vars:
      contain_list:
        - php
        - mysql
    debug:
      msg:
      - '{{ hosts | selectattr("pkg", "in", contain_list) }}'

↑の内容のplayで実行すると以下の通り。

ok: [localhost] => 
  msg:
  - []

(おまけ) reject() / rejectattr()

条件に合致しない要素が欲しい場合は、逆の処理となるreject()rejectattr()を使用する。

見るべきドキュメント等

select() / selectattr() フィルター本体

ソースも確認すると理解が深まる。
実は中身はどちらも(さらに言うとreject()とrejectattr()も)select_or_reject()をコールしてるのよね。

Jinja2 Tests

まずJinja2 Templateのページ。
ここの「List of Builtin Tests」のリストにあるTestsが使用できる。
一致不一致や型チェックなどの基本的なTestはここで用意されている。

jinja.palletsprojects.com

ページ右下でバージョンを選べるが、これはAnsibleで使用しているPythonパッケージのJinja2のバージョンに合わせればよい。

Ansible Tests

Jinja2 TemplateのTest以外にAnsibleで内蔵されているTestもある。
Filterと同じくコレクション等のpluginリストにはないが、このページで一通り確認することができる。

docs.ansible.com

Testsのソースはcallbackやlookupなどのほかのpluginと同様に、testというディレクトリ以下にある。

まとめ

selectattr() + map()で指定キー配下の情報のみ抽出の例はよく見かけるけど、select()selectattr()についての情報があまりなかった気がするのでまとめてみた。
どちらのFilterもAnsibleではなくJinja2の機能で、使用する主要なTest(被演算子)もJinja2のドキュメントに記載があるのがポイント。
また、「select()selectattr()の引数で指定するのはFilterでなくTest」であることをおさえておけば、やりたいことを探しやすいと思う。(自分はここが曖昧だった)
※ 現状FilterとTestはCollection Indexから辿れず、AnsibleのFilterTestJinja2のドキュメントを見るしかないと思う。

select()selectattr()は、Perlだとgrep()相当でPowerShellだとWhere-Object相当かな。
今回は扱ってないmapは、Perlmap()相当でPowerShellだとForEach-Object相当のはず。

比較的単純なフィルタリングであればselect()+selectattr()で済ませて良いと思うが、抽出条件が複雑(深い位置にある値でフィルタリングして別要素の値を抜き出す、など)になってくると標準機能だけだとかえって分かりづらくなるため、json_query()なんかを使った方が良かったりするケースも出てくるので、要件によってやり方は考慮した方がよい。

(疑問 追記あり) builtin 以外の Filter / Test は…

Collection IndexからFilterとTestを辿れないということは、FilterTestに載っていない(特にbuiltin以外の)Filter / Testの情報はどこから知ればいいか不明…

例えばcommunity.generalにあるjc Filterとか。

zaki-hmkc.hatenablog.com

リポジトリのプラグインのディレクトリを見るしかないのかな。

2021.07追記:
community.generalコレクションのフィルタのドキュメントがいつのまにか追加されてた -> community.general Filter Guide — Ansible Documentation
コレクションによってはプラグインごとのドキュメントと別に独自のガイドが用意されるようになってる。

サンプルplaybook (on GitHub)


  • 2021.07.09 リンク切れを修正
  • 2021.07.28 「演算子」→「被演算子」に誤り修正