Ansibleのlookup pluginについて調べてみた

Ansibleのlookup pluginについて、あまり良く知らなかったので調べてみた。

長いので先にまとめると、

* lookup pluginはwith_*の処理、つまりloopを作るための処理が書かれたプラグインである。
* 他に`lookup('プラグイン名', 引数)`の形で呼び出すこともできる。
* lookup pluginはAnsibleを実行しているコンピュータで実行される。操作対象のコンピュータ上ではない。
* pluginはPythonで実装される。
* 自作したlookup pluginはplaybookのあるディレクトリにlookup_pluginsというディレクトリを作り配置するか、ansible.cfgの[defaults]セクション、lookup_plugins項に指定されたディレクトリに配置すると利用できる。

参考とした公式ドキュメントは次である。
http://docs.ansible.com/playbooks_lookups.html
http://docs.ansible.com/playbooks_loops.html

以下の文章には記述の仕方のサンプルはあまりないので、サンプルを見たいなら上記の公式ドキュメントを参照すること。

**2015-04-29追記**: 1.9で追加されたlookup pluginとwantlistオプションについて記述を追加した。

# 概要

lookup pluginには2種類の呼び出し方がある。

* `lookup('プラグイン名', 引数)`として呼び出す
* taskのアトリビュート[^1]の形で`with_プラグイン名: 引数`として呼び出す

[^1]: 公式の呼び方は特にないが、[Ansibleのplaybookで使用できるアトリビュートの一覧](http://qiita.com/yunano/items/8494e785390360011a88)でアトリビュートと呼ぶことにしたので統一した。

この文章では以降、前者をlookup形式、後者をwith形式と呼ぶことにする。
with_itemsに代表されるwith形式は、taskの中でloop処理を行うのに良く使用されるが、これがlookup pluginの呼び出しだと意識されていることはあまりないと思われる。

# 種類

公式に提供されているlookup pluginは lib/ansible/runner/lookup_plugins にある。
ファイルを探す処理やコマンドを実行する処理などもあるが、どの処理も操作される側のコンピュータではなく、Ansibleを実行している側のコンピュータで行われることに注意。

1.9.1現在、公式に提供されているものはこれだけある。

## 値を1つだけ返すもの

* **consul_kv**

<dl>
<dt>引数</dt>
<dd>(キー)</dd>
<dt>引数とともに指定可能な変数</dt>
<dd>recurse=True/False [デフォルト：False]</dd>
<dd>index=(index) [デフォルト：None]</dd>
<dd>token=(ACL token) [デフォルト：None]</dd>
<dt>返り値</dt>
<dd>OSの環境変数ANSIBLE_CONSUL_URL（デフォルト: http://localhost:8500 ）にあるconsulの(キー)の値</dd>
</dl>

「値を1つだけ返すもの」カテゴリに入れてはいるが、recurse=Trueの場合、(キー)に前方一致したすべてのキーの値をリストにして返す。

使用するにはpythonのpython-consulモジュールが必要となるのでpip等でインストールしておく。

* **csvfile**

<dl>
<dt>引数</dt>
<dd>(キー)</dd>
<dt>引数とともに指定可能な変数</dt>
<dd>file=(ファイル名) [デフォルト：ansible.csv]</dd>
<dd>default=(デフォルト値) [デフォルト：None]</dd>
<dd>delimiter=(デリミタ) [デフォルト：タブ]</dd>
<dd>col=(カラム番号) [デフォルト：1]</dd>
<dt>返り値</dt>
<dd>(ファイル名)の各行を(デリミタ)で分割し、1項めが(キー)の行があれば(カラム番号+1)項め。なければ(デフォルト値)</dd>
</dl>

* **dig**

<dl>
<dt>引数</dt>
<dd>「(名前)」、あるいは「(名前)/(レコード種別)」</dd>
<dt>引数とともに指定可能な変数</dt>
<dd>qtype=(レコード種別) [デフォルト：A]</dd>
<dt>返り値</dt>
<dd>(名前)のDNS検索結果</dd>
</dl>

qtypeは引数で(レコード種別)を指定しなかった場合のみ指定する。

指定可能なレコード種別と、何が返ってくるかはソースから以下抜粋した。
複数項目が返ってくるレコードはスペース区切りでjoinされる。

```
A           : ['address']
AAAA        : ['address']
CNAME       : ['target']
DNAME       : ['target']
DLV         : ['algorithm', 'digest_type', 'key_tag', 'digest']
DNSKEY      : ['flags', 'algorithm', 'protocol', 'key']
DS          : ['algorithm', 'digest_type', 'key_tag', 'digest']
HINFO       : ['cpu', 'os']
LOC         : ['latitude', 'longitude', 'altitude', 'size', 'horizontal_precision', 'vertical_precision']
MX          : ['preference', 'exchange']
NAPTR       : ['order', 'preference', 'flags', 'service', 'regexp', 'replacement']
NS          : ['target']
NSEC3PARAM  : ['algorithm', 'flags', 'iterations', 'salt']
PTR         : ['target']
RP          : ['mbox', 'txt']
SOA         : ['mname', 'rname', 'serial', 'refresh', 'retry', 'expire', 'minimum']
SPF         : ['strings']
SRV         : ['priority', 'weight', 'port', 'target']
SSHFP       : ['algorithm', 'fp_type', 'fingerprint']
TLSA        : ['usage', 'selector', 'mtype', 'cert']
TXT         : ['strings']
```

「値を1つだけ返すもの」カテゴリに入れてはいるが、DNSラウンドロビン時のAレコードなど複数の値が返ってきた場合はリストにして返す。

使用するにはpythonのdnspythonモジュールが必要となるのでpip等でインストールしておく。

* **dnstxt**

<dl>
<dt>引数</dt>
<dd>ホスト名</dd>
<dt>返り値</dt>
<dd>ホスト名のDNS TXTレコード</dd>
</dl>

使用するにはpythonのdnspythonモジュールが必要となるのでpip等でインストールしておく。

* **env**

<dl>
<dt>引数</dt>
<dd>環境変数名</dd>
<dt>返り値</dt>
<dd>OSの環境変数の値</dd>
</dl>

* **etcd**

<dl>
<dt>引数</dt>
<dd>キー</dd>
<dt>返り値</dt>
<dd>OSの環境変数ANSIBLE_ETCD_URL（デフォルト: http://127.0.0.1:4001 ）にあるetcdのキーの値</dd>
</dl>

* **file**

<dl>
<dt>引数</dt>
<dd>ファイルのパス</dd>
<dt>返り値</dt>
<dd>ファイルの中身の文字列</dd>
</dl>

* **first_found**

<dl>
<dt>引数</dt>
<dd>ファイルのパスのリスト</dd>
<dt>返り値</dt>
<dd>リストの上位からファイルが存在するかを調べ、一番最初に存在したファイルのパス</dd>
</dl>

あるいは、

<dl>
<dt>引数</dt>
<dd>リストを値として持つpathsキーとリストを値として持つfilesキーで構成されたディクショナリ</dd>
<dt>返り値</dt>
<dd>pathsに指定されたディレクトリの上位から、filesに与えられたファイル名の上位からファイルが存在するかを調べ、一番最初に存在したファイルのパス</dd>
</dl>

* **password**

<dl>
<dt>引数</dt>
<dd>(ファイル名)</dd>
<dt>引数とともに指定可能な変数</dt>
<dd>length=(パスワードの長さ) [デフォルト：20]</dd>
<dd>encrypt=(ハッシュ化アルゴリズムの種類) [デフォルト：ハッシュ化しない]</dd>
<dd>chars=(パスワードに使う文字種) [デフォルト：英大文字小文字数字.,:-_ ]</dd>
<dt>返り値</dt>
<dd>(ファイル名)が存在しなければ、(パスワードに使う文字種)から選んだ文字数が(パスワードの長さ)の文字列を(ファイル名)に保存した後、(ファイル名)に保存してあるパスワードを(ハッシュ化アルゴリズムの種類)でハッシュ化して返す。</dd>
</dl>

ハッシュ化アルゴリズムの種類は http://docs.ansible.com/playbooks_prompts.html に書かれているものが使用できる。

ハッシュ化を行うにはpythonのpasslibモジュールが必要となるのでpip等でインストールしておく。

* **pipe**

<dl>
<dt>引数</dt>
<dd>シェルコマンド</dd>
<dt>返り値</dt>
<dd>実行した結果の標準出力への出力</dd>
</dl>

* **random_choice**

<dl>
<dt>引数</dt>
<dd>リスト</dd>
<dt>返り値</dt>
<dd>ランダムに選んだリストの要素1つ</dd>
</dl>

* **redis_kv**

<dl>
<dt>引数</dt>
<dd>(URL),(キー)</dd>
<dt>返り値</dt>
<dd>(URL)にあるredisの(キー)の値</dd>
</dl>

URLを指定しなかった時のデフォルト値は「redis://localhost:6379」である。

使用するにはpythonのredisモジュールが必要となるのでpip等でインストールしておく。

* **template**

<dl>
<dt>引数</dt>
<dd>jinja2テンプレートファイルのパス</dd>
<dt>返り値</dt>
<dd>そのテンプレートファイルを解釈した結果の文字列</dd>
</dl>

## 値を複数返すもの

* **cartesian**

<dl>
<dt>引数</dt>
<dd>各要素がリストであるリスト</dd>
<dt>返り値</dt>
<dd>リストの各要素の直積を取り、新しい各要素をリストにしたものを要素として持つリスト</dd>
</dl>

後述するnestedと全く違いがないように見える。

例：[['a', 'b', 'c'], ['d', 'e']] ⇒ [['a', 'd'], ['a', 'e'], ['b', 'd'], ['b', 'e'], ['c', 'd'], ['c', 'e']]

* **dict**

<dl>
<dt>引数</dt>
<dd>ディクショナリ</dd>
<dt>返り値</dt>
<dd>ディクショナリの各項目をkey=(キー),value=(値)の形にし、リストにしたもの</dd>
</dl>

例：{'key1': 'value1', 'key2': 'value2'} ⇒ [{'key': 'key1', 'value': 'value1'}, {'key': 'key2', 'value': 'value2'}]

* **fileglob**

<dl>
<dt>引数</dt>
<dd>パス（*と?をワイルドカードとして使用可）</dd>
<dt>返り値</dt>
<dd>引数の条件に一致するファイルの絶対パスのリスト</dd>
</dl>

* **flattened**

<dl>
<dt>引数</dt>
<dd>リスト</dd>
<dt>返り値</dt>
<dd>リストが多段ネストしていてもフラットにしたリスト</dd>
</dl>

例：['a', ['b', 'c'], [['d', 'e', 'f']]] ⇒ ['a', 'b', 'c', 'd', 'e', 'f']

* **indexed_items**

<dl>
<dt>引数</dt>
<dd>リスト</dd>
<dt>返り値</dt>
<dd>0から始まるインデックス番号とリストの要素を2つ組のタプルにしたリスト</dd>
</dl>

ただしタプルはwith形式で使用された場合リストに変換される。

例：['a', 'b', 'c', 'd', 'e', 'f'] ⇒ [(0, 'a'), (1, 'b'), (2, 'c'), (3, 'd'), (4, 'e'), (5, 'f')]

* **inventory_hostnames**

<dl>
<dt>引数</dt>
<dd>グループ名</dd>
<dt>返り値</dt>
<dd>インベントリの与えられたグループに所属するホスト名のリスト</dd>
</dl>

with形式でしか使用できない。

* **items**

<dl>
<dt>引数</dt>
<dd>リスト</dd>
<dt>返り値</dt>
<dd>ネストを1段解いてフラットにしたリスト</dd>
</dl>

例：['a', ['b', 'c'], [['d', 'e', 'f']]] ⇒ ['a', 'b', 'c', ['d', 'e', 'f']]

* **lines**

<dl>
<dt>引数</dt>
<dd>シェルコマンド</dd>
<dt>返り値</dt>
<dd>実行した結果の標準出力への出力を行ごとに分割したリスト</dd>
</dl>

* **nested**

<dl>
<dt>引数</dt>
<dd>各要素がリストであるリスト</dd>
<dt>返り値</dt>
<dd>リストの各要素の直積を取り、新しい各要素をリストにしたものを要素として持つリスト</dd>
</dl>

前述したcartesianと全く違いがないように見える。

例：[['a', 'b', 'c'], ['d', 'e']] ⇒ [['a', 'd'], ['a', 'e'], ['b', 'd'], ['b', 'e'], ['c', 'd'], ['c', 'e']]

* **sequence**

<dl>
<dt>引数を指定する場合</dt>
<dd>(第1要素の数字-)(最終要素の数字)(/ステップ)(:フォーマット)</dd>
<dt>変数を渡す場合</dt>
<dd>start=(第1要素の数字) [デフォルト：1]</dd>
<dd>end=(最終要素の数字)</dd>
<dd>count=(リスト要素数)</dd>
<dd>stride=(ステップ) [デフォルト：1]</dd>
<dd>format=(フォーマット) [デフォルト：%d]</dd>
<dt>返り値</dt>
<dd>(最終要素の数字)を指定した場合、(第1要素の数字)から(ステップ)ずつ足して(最終要素の数字)を超えないまでの数字の集合をそれぞれ(フォーマット)に合わせて出力したリスト</dd>
<dd><br>(リスト要素数)を指定した場合、(第1要素の数字)から(ステップ)ずつ足した数字をそれぞれ(フォーマット)に合わせて出力し、(リスト要素数)だけの要素を持つリスト</dd>
</dl>

「引数を指定する方式」と「変数を渡す方式」のどちらかを選ぶことができる。
(最終要素の数字)と(リスト要素数)はどちらか片方だけを必ず指定する。(リスト要素数)を指定するには「変数を渡す方式」を選ぶ必要がある。
「引数を指定する方式」の各デフォルト値は「変数を渡す方式」と同じ。

例：
'start=3 count=3 stride=3 format=1%d' ⇒ [13, 16, 19]
['6/2'] ⇒ [1, 3, 5]

* **subelements**

<dl>
<dt>引数</dt>
<dd>第1要素がリストかディクショナリ、第2要素がその中から値（リスト）を取り出したいキーの名前である、計2要素のリスト</dd>
<dt>返り値</dt>
<dd>まず第1要素がディクショナリの場合は、そのそれぞれの値を要素に持つリストにする。（それぞれのキーはなかったことになる）<br>
第1要素のリストの各要素から、第2要素で指定されたキーとその値を取り除く。以下このリストをv1と呼ぶ。取り除かれた方からは値を取得する。以下これをv2と呼ぶ。v2はリストでないとエラーになる。<br>
v1とv2の直積を取り、新しい各要素をタプルにしたものを要素として持つリストを返す。
</dd>
</dl>

ただしタプルはwith形式で使用された場合リストに変換される。

要はwith_subelementsは一定のディクショナリ構造を要素として持つリストに手を突っ込んで、指定したディクショナリのキーの値が持つリストの要素数の総計の回数だけループを回そうというものだ。
これはwith_*とregisterを組み合わせて使った後、その中からregisterした変数（というか「(変数).results」）から値を取り出したい場合に行いたくなることがある。

例：[{'k1': {'s1': ['k1s1v1'], 's2': ['k1s2v1', 'k1s2v2'], 's3': ['k1s3v1']}, 'k2': {'s1': ['k2s1v1'], 's2': ['k2s2v1']}}, 's2'] ⇒ [({'s1': ['k2s1v1']}, 'k2s2v1'), ({'s3': ['k1s3v1'], 's1': ['k1s1v1']}, 'k1s2v1'), ({'s3': ['k1s3v1'], 's1': ['k1s1v1']}, 'k1s2v2')]

* **together**

<dl>
<dt>引数</dt>
<dd>各要素がリストであるリスト</dd>
<dt>返り値</dt>
<dd>転置したリスト</dd>
</dl>

例：[['a', 'b', 'c'], ['d', 'e', 'f']] ⇒ [['a', 'd'], ['b', 'e'], ['c', 'f']]

* **url**

<dl>
<dt>引数</dt>
<dd>url</dd>
<dt>返り値</dt>
<dd>レスポンスボディを行ごとに分割した文字列のリスト</dd>
</dl>

# 呼び出し方

上記で「値を1つだけ返すもの」と「値を複数返すもの」の2つに分けた。
通常「値を1つだけ返すもの」はlookup形式で、「値を複数返すもの」はwith形式で使うことが多くなるだろう。

次は「値を1つだけ返すもの」であるcsvfileをlookup形式で呼び出す例である。

```
- hosts: all
  tasks:
  - debug: msg="{{ lookup('csvfile', 'key1 col=2') }}"
```

次は「値を複数返すもの」であるitemsをwith形式で呼び出す例である。これは特に良く見かけるだろう。

```
- hosts: all
  tasks:
  - debug: msg="{{ item }}"
    with_items:
    - val1
    - val2
```

多くなる、とは言ったが「値を1つだけ返すもの」をwith形式で使うことに支障はない。
次はAnsibleを実行しているコンピュータの指定したパスにファイルがあれば操作される側のコンピュータにそのファイルをコピーする、というplayである。

```
- hosts: all
  tasks:
  - copy: src={{ item }} dest=/etc/xxx
    when: item | length
    with_pipe: find /etc/xxx -type f -name yyy.cfg | xargs echo
```

ここではpipeをwith形式で使用してみた。pipeは前記の通りコマンドを引数として渡し、標準出力の出力を返すlookup pluginである。
with形式で書かれておりloopのように見えるがpipeの返り値は常に1つなので、この処理は1回しか実行されない。

なお、pipeに与えたコマンドはAnsibleを実行しているコンピュータにファイルがあるかどうかを検査するが、コマンドを実行した結果、検査対象のディレクトリが存在しないなどでリターンコードが0以外になるとansible-playbookがエラー終了してしまう[^2]ので、` | xargs echo`でリターンコードが0になるようにしている。

[^2]: これはignore_errorsやfailed_whenを以ってしても防ぐことができない。

一方、「値を複数返すもの」をlookup形式で使用するのはどうだろうか。
lookup pluginは返す値の数が1つであっても複数であってもリストにして返すが、lookup形式だとさらにそのリストをカンマ(,)でjoinする、という処理が入る。

次のようなplaybookを作った場合、

```yaml:lookup_items1.yml
- hosts: all
  gather_facts: no
  tasks:
  - debug: msg={{ lookup('items', ['aaa', ['bbb']]) }}
```

実行結果は次のようになる。

```
# ansible-playbook lookup_items1.yml -i hosts

PLAY [all] ********************************************************************

TASK: [debug msg=aaa,bbb] *****************************************************
ok: [10.0.2.103] => {
    "msg": "aaa,bbb"
}
```

次は先程のものと同じ意味となるはずが1.8.2ではエラーになっていた。
1.9.1では問題ない。

```yaml:lookup_items2.yml
- hosts: all
  gather_facts: no
  vars:
    key1:
    - aaa
    -
      - bbb
  tasks:
  - debug: msg={{ lookup('items', key1) }}
```

1.9以降オプションにwantlist=Trueを指定するとカンマでjoinせず、そのままリストで返すようになった。

```yaml:lookup_items3.yml
- hosts: all
  gather_facts: no
  tasks:
  - debug: msg="{{ lookup('items', ['aaa', ['bbb']], wantlist=True) }}"
```

```
# ansible-playbook lookup_items3.yml -i hosts

PLAY [all] ********************************************************************

TASK: [debug msg="['aaa', 'bbb']"] ********************************************
ok: [10.0.2.103] => {
    "msg": "['aaa', 'bbb']"
}
```

debugはリストをもらっても""で囲まないと正しく出力できないので注意。

また、lookup形式はテンプレート内で使用できるので、以下のようなテンプレートを作ってtemplateモジュールで実行してみる。

```lookup_template.j2
{%- for s in lookup('items', ['aaa', ['bbb']], wantlist=True) %}
{{ s }}
{% endfor %}
```

```yaml:lookup_template.yml
- hosts: all
  gather_facts: no
  tasks:
  - template: src=lookup_template.j2 dest=/tmp/lookup_template.txt
```

実行後、/tmp/lookup_template.txtを見てみる。

```
# cat /tmp/lookup_template.txt
aaa
bbb

```

wantlist=Trueによってリストで返ってきていることがわかる。

ちなみに、wantlist=Trueを指定していなければどうなるだろう。

```lookup_template2.j2
{%- for s in lookup('items', ['aaa', ['bbb']]) %}
{{ s }}
{% endfor %}
```

こうなる。

```
# cat /tmp/lookup_template2.txt
a
a
a
,
b
b
b
```

なお、lookup形式だと返り値がタプルのリストになっているものは想定していないようで、indexed_itemsはエラーになる。
ちなみに同じように返り値がタプルのリストであるsubelementsはそれ以前にlookup形式でどう指定したら良いかがわからない。

# 自作する

lookup pluginを自作するには lib/ansible/runner/lookup_plugins にある既存のものを参考にすれば良い。
pluginはPythonで実装する必要がある。

自作したlookup pluginはplaybookのあるディレクトリにlookup_pluginsというディレクトリを作り配置するか、ansible.cfgの[defaults]セクション、lookup_plugins項に指定されたディレクトリに配置すると利用できる。

サンプルとして、2つの要素を持つリストとして文字列とデリミタを引数に与えると、文字列をデリミタで分割した文字列のリストを返すlookup pluginを作ってみた。

```py:lookup_plugins/split.py
import ansible.utils as utils
import ansible.errors as errors

class LookupModule(object):
  def __init__(self, basedir=None, **kwargs):
    self.basedir = basedir

  def run(self, terms, inject=None, **kwargs):
    terms = utils.listify_lookup_plugin_terms(terms, self.basedir, inject)

    if len(terms) != 2:
      raise errors.AnsibleError("split lookup plugin requires 2 strings")

    return str(terms[0]).split(str(terms[1]))
```

`terms = utils.listify_lookup_plugin_terms(terms, self.basedir, inject)`の行までは固定で、そこから必要な処理を書いていけば良い。
つまり今回自分で書いた部分は空白行除けば3行だけである。

このlookup pluginは次のように利用できる。

```yaml:lookup_split.yml
- hosts: all
  gather_facts: no
  tasks:
  - debug: msg={{ item }}
    with_split:
    - 123456
    - 23
```

```
# ansible-playbook lookup_split.yml -i hosts

PLAY [all] ********************************************************************

TASK: [debug msg={{ item }}] **************************************************
ok: [10.0.2.103] => (item=1) => {
    "item": "1",
    "msg": "1"
}
ok: [10.0.2.103] => (item=456) => {
    "item": "456",
    "msg": "456"
}
```

なお私見だが、lookup pluginはwith形式で呼び出すために作るのは良いが、lookup形式で呼び出すものを作るくらいならfilter pluginを作る方が、playbookの見た目も良くなるし、filterのチェーンもできるし、リストで返してもjoinされるようなこともないしで、より良いように思える。
本題から外れるのでここでは詳しく説明しないがfilter pluginもlookup pluginとさほど変わらない労力で書くことができる。

----