Ansibleで定義した変数を取得する

# はじめに
Ansible で定義した変数を [ansible_spec](https://github.com/volanja/ansible_spec) などのツールや自分で書いたプログラムなどで使いまわしたいことがありました。ざっと探して手軽にやりたいことがすぐにできるようなものがなさそうだったので、変数を抽出するようなものを作成しました(ansible のコードを読んで適宜利用するのが正しそうですが、読むのも手間がかかるのと ansible が行えること全てを考慮する必要はなかったので作成しました)。ここでは作成したものについてまとめています。

# 要件
取得する変数は以下のようなものを想定しています。

* 単純な変数や配列、辞書やそれらを合わせたもの
* 変数の値を参照する変数(多重に参照するものも含む)
* 計算式や split などが含まれるもの

具体的には以下の yml ファイルから以下に示す結果が得られば良いとしています。

■ymlファイルの例

```yml
simple: 'simple variable'
simple_array:
   - 'array1'
   - 'array2'
simple_dict:
   dict1: 'dict1'
   dict2: 'dict1'
dict_array:
   dict_array1:
       - 'array1'
       - "{{ simple }}"

simple_ref: "{{ simple }}/{{ simple }}"
simple_array_ref: "{{ simple_array }}"
simple_dict_ref: "{{ simple_dict.dict1 }}"
dict_array_ref: "{{ dict_array.dict_array1[0] }}"

multi_ref: "{{ simple_ref }}"

simple_int: 10
simple_float: 1.5
sum: "{{ simple_int + simple_float + 10 }}"

split: "{{ 'split1/split2'.split('/') }}"
```
■結果として得たいもの(今回作成したもので実際に得られるもの)

```json:result.json
{"simple": "simple variable", "simple_array": ["array1", "array2"], "simple_dict": {"dict1": "dict1", "dict2": "dict1"}, "dict_array": {"dict_array1": ["array1", "simple variable"]}, "simple_ref": "simple variable/simple variable", "simple_array_ref": ["array1", "array2"], "simple_dict_ref": "dict1", "dict_array_ref": "array1", "multi_ref": "simple variable/simple variable", "simple_int": 10, "simple_float": 1.5, "sum": "21.5", "split": ["split1", "split2"]}
```

上記では yml ファイルは1つしかありませんが、Ansible ではインベントリなど複数箇所に変数を定義したyml ファイルを置くので、ファイルが複数あっても読み取れるようにします。

# 作成したもの
jinja2 を利用するため、Python で実装しています。

```python:parse_variable.py

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from jinja2.nativetypes import NativeEnvironment
from jinja2 import Environment
import re
import yaml
import json

j2_env = Environment()
j2_native_env = NativeEnvironment()


def recursive_update(all_dict, target):
    val_pattern = r'\{\{\s*.*?\s*\}\}'

    if isinstance(target, str) and re.search(val_pattern, target):
        res = variable_check(all_dict, target)
        return res

    # valueが配列なら再度解析する
    elif isinstance(target, (list)):
        res = []
        for next_target in target:
            res.append(recursive_update(all_dict, next_target))
        return res
    # valueが辞書なら再度解析する
    elif isinstance(target, dict):
        res = {}
        for k, next_target in target.items():
            res[k] = recursive_update(all_dict, next_target)
        return res

    return target


def variable_check(all_dict, target):
    val_pattern = r'\{\{\s*.*?\s*\}\}'
    lookup_pattern = r'lookup(.*?)'
    array_pattern = r'\[\d+\]'
    matched_list = re.findall(val_pattern, str(target))
    res = target
    res_dict = {}
    non_native_flag = False
    for match in matched_list:
        # 以下のような変数に対応する
        # {{ num * 5 }}
        # {{ '1.5' | replace('.', '') }}
        # {{ array[0] }}
        match = re.sub('\{\{\s*|\s*\}\}', '', match)
        match_splited = re.split('\s+|\|', match)
        for split in match_splited:
            # 配列なら[\d+]を削らないと対象を探せないので削除
            split = re.sub(array_pattern, '', split)
            # array等の.で表されるものはスタートだけとって、全て辿る
            split = split.split('.')[0]
            if split in all_dict:
                res_dict[split] = recursive_update(all_dict, all_dict[split])
                # native_envで"aaa{{ hogehoge }}1"のような形式がうまく扱えなかったので別途対応
                if not re.match('^\[.*\]$|^\{.*\}$', str(res_dict[split])):
                    non_native_flag= True
    # loopupのようなライブラリは実行できないので、その場合はそのままの値を入れる
    if re.search(lookup_pattern, str(target)):
        res = target
    else:
        if non_native_flag:
            res = j2_env.from_string(str(target)).render(res_dict)
        else:
            res = j2_native_env.from_string(str(target)).render(res_dict)

        # unicodeを変更
        if not isinstance(res, list) and not isinstance(res, dict):
            res = str(res)

    return res


def get_ansible_vars():
    var_dict = {}
    # 優先度が弱い順に読み込み
    file_list = ['./inventories/staging/group_vars/all/main.yml',
                 './roles/common/vars/main.yml']
    read_yml_files(var_dict, file_list)

    return var_dict


def read_yml_files(var_dict, file_list):
    for yml in file_list:
        with open(yml, "r") as f:
            var_dict.update(yaml.load(f))


if __name__ == '__main__':
    all_vars_dict = get_ansible_vars()
    print(all_vars_dict)

    result = {}
    for k, target in all_vars_dict.items():
        result[k] = recursive_update(all_vars_dict, target)

    # 標準出力で結果を出す
    print(json.dumps(result))
```

■実行

```shell

$ python parse_variable.py 
{"simple": "simple variable", "simple_array": ["array1", "array2"], "simple_dict": {"dict1": "dict1", "dict2": "dict1"}, "dict_array": {"dict_array1": ["array1", "simple variable"]}, "simple_ref": "simple variable/simple variable", "simple_array_ref": ["array1", "array2"], "update_check": "role", "simple_dict_ref": "dict1", "dict_array_ref": "array1", "multi_ref": "simple variable/simple variable", "simple_int": 10, "simple_float": 1.5, "sum": "21.5", "split": ["split1", "split2"]}
```
実行には以下のパッケージ・ファイルが必要

■パッケージ

```shell
pip install jinja2==2.10
```

■ファイル(実行テスト用に適当にymlを記述したもの)

```yml:inventories/staging/group_vars/all/main.yml

simple: 'simple variable'
simple_array:
   - 'array1'
   - 'array2'
simple_dict:
   dict1: 'dict1'
   dict2: 'dict1'
dict_array:
   dict_array1:
       - 'array1'
       - "{{ simple }}"

simple_ref: "{{ simple }}/{{ simple }}"
simple_array_ref: "{{ simple_array }}"

update_check: 'inventory'
```

```yml:roles/common/vars/main.yml
simple_dict_ref: "{{ simple_dict.dict1 }}"
dict_array_ref: "{{ dict_array.dict_array1[0] }}"

multi_ref: "{{ simple_ref }}"

simple_int: 10
simple_float: 1.5
sum: "{{ simple_int + simple_float + 10 }}"

split: "{{ 'split1/split2'.split('/') }}"

update_check: 'role'
```

# 詳細
変数抽出は以下の2つの処理を行っています。以降で、詳細を記述します。

1. 対象となる yml ファイルを全て読み込む。
2. 読み込んだファイルの情報をもとに変数抽出する。

## ymlファイルの読み取り
Ansible では roles や group_vars、 inventories 以下などに変数を記述した yml ファイルを配置します
(どういった配置になるかなどは [Best Practice](https://docs.ansible.com/ansible/latest/user_guide/playbooks_best_practices.html) を参考)。
また、変数には [優先度](https://qiita.com/KeijiYONEDA/items/721407cbe418b3d532ed) があるため、それを考慮する必要があります。
下記のように 辞書 に更新する形で yml ファイルを読み込むことで、優先度を考慮することができます。

```python:
def get_ansible_vars():
    var_dict = {}
    # 優先度が低い順に読み込み
    file_list = ['./inventories/staging/group_vars/all/main.yml',
                 './roles/common/vars/main.yml']
    read_yml_files(var_dict, file_list)
    return var_dict


def read_yml_files(var_dict, file_list):
    for yml in file_list:
        with open(yml, "r") as f:
            # 後からreadされた変数優先度が高ければ上書きする
            var_dict.update(yaml.load(f))
```
上記を実行すると下記のように単純に変数を含んだ辞書ができます。

```python:

{'simple': 'simple variable', 'simple_array': ['array1', 'array2'], 'simple_dict': {'dict1': 'dict1', 'dict2': 'dict1'}, 'dict_array': {'dict_array1': ['array1', '{{ simple }}']}, 'simple_ref': '{{ simple }}/{{ simple }}', 'simple_array_ref': '{{ simple_array }}', 'update_check': 'role', 'simple_dict_ref': '{{ simple_dict.dict1 }}', 'dict_array_ref': '{{ dict_array.dict_array1[0] }}', 'multi_ref': '{{ simple_ref }}', 'simple_int': 10, 'simple_float': 1.5, 'sum': '{{ simple_int + simple_float + 10 }}', 'split': "{{ 'split1/split2'.split('/') }}"}
```

この時点で、2つの yml ファイルに記述されている update_check の値が'role'になっていることから、
変数の読み込みの優先度をつけれていることがわかります。

## 変数抽出
ymlファイルの読み取りで作成した辞書を対象に処理を実施します。recursive_update、variable_check 関数を用います。
### recursive_update
```python:
def recursive_update(all_dict, target):
    val_pattern = r'\{\{\s*.*?\s*\}\}'

    if isinstance(target, str) and re.search(val_pattern, target):
        res = variable_check(all_dict, target)
        return res

    # valueが配列なら再度解析する
    elif isinstance(target, list):
        res = []
        for next_target in target:
            res.append(recursive_update(all_dict, next_target))
        return res
    # valueが辞書なら再度解析する
    elif isinstance(target, dict):
        res = {}
        for k, next_target in target.items():
            res[k] = recursive_update(all_dict, next_target)
        return res

    return target
```

■機能
ymlの変数の値を返す。
■引数
all_dict(dict): 変数の参照先の候補
target(str): 対象となる変数の値(例、{{ simple_ref }}, 'simple variable'など)
■戻り値
対象の変数の値
■処理内容
yml ファイルから読み込んだ変数の情報を与えることで、求めたい変数の値を返します。例えば、上記で[要件](#要件)に挙げている、{{ simple_ref }} を target として与えると "simple variable/simple variable"が取得できます。
処理の内容としてはtargetとして与えられた変数の値を見て、1~4のどれかの処理を実行します。

1. target の値が{{ 変数名 }}といった形であれば、variable_check 関数に target の値を渡し、その結果を返す。
2. target の型が配列であれば、配列の各値を順に recursive_update 関数に渡し、再帰的に処理をする。
3. target の型が辞書であれば、辞書の value の値を順に recursive_update 関数に渡し、再帰的に処理をする。
4. target が上記以外の(配列, 辞書でなく、変数を参照しない)値であればそのまま返す。
 
それぞれ具体例を挙げると以下のようになります。
1. target の値が{{ simple_ref }}であれば variable_check 関数が対応する値(この場合は"simple variable/simple variable")を返すため、この結果を返す。
2. target の値が['array1', 'array2']といった配列であれば'array1', 'array2'それぞれを target として recursive_update 関数に渡して再帰的に処理を実行する。最終的には配列を返す。
3. target の値が{'dict1': 'dict1', 'dict2': 'dict1'}といった辞書であればそれぞれ、辞書の値である'dict1'、'dict2'を target として recursive_update 関数に渡して再帰的に処理を実行する。最終的には辞書を返す。
4. target の値が'simple variable'など単なる値であればそのまま返す。

再帰的に処理を実施するため{'dict_array1': ['array1', '{{ simple }}']}(上記[要件](#要件)のdict_array)のような複雑な値でも、

1. {'dict_array1': ['array1', '{{ simple }}']}を処理、辞書なので値を再処理する。
2. ['array1', '{{ simple }}']を処理、配列なので各値を再処理する。
3. 'array1'を処理、単なる文字列なので"array1"が返る。
4. '{{ simple }}'を処理、variable_check関数を実行し、"simple variable"が返る。
5. 2の処理結果として["array1", "simple variable"]が返る。
6. 1の処理結果として{"dict_array1": ["array1", "simple variable"]}が返る。

のように対応する値を返すことができます。考え方は下記と同様です。
([複雑なJSONから特定のデータを取り出す](https://qiita.com/jumpyoshim/items/3b138d3ebbdaa873eaa5))
### variable_check
```python
def variable_check(all_dict, target):
    val_pattern = r'\{\{\s*.*?\s*\}\}'
    lookup_pattern = r'lookup(.*?)'
    array_pattern = r'\[\d+\]'
    matched_list = re.findall(val_pattern, str(target))
    res = target
    res_dict = {}
    non_native_flag = False
    for match in matched_list:
        # 以下のような変数に対応する
        # {{ num * 5 }}
        # {{ '1.5' | replace('.', '') }}
        # {{ array[0] }}
        match = re.sub('\{\{\s*|\s*\}\}', '', match)
        match_splited = re.split('\s+|\|', match)
        for split in match_splited:
            # 配列なら[\d+]を削らないと対象を探せないので削除
            split = re.sub(array_pattern, '', split)
            # array等の.で表されるものはスタートだけとって、全て辿る
            split = split.split('.')[0]
            if split in all_dict:
                res_dict[split] = recursive_update(all_dict, all_dict[split])
                # native_envで"aaa{{ hogehoge }}1"のような形式がうまく扱えなかったので別途対応
                if not re.match('^\[.*\]$|^\{.*\}$', str(res_dict[split])):
                    non_native_flag= True
    # loopupのようなライブラリは実行できないので、その場合はそのままの値を入れる
    if re.search(lookup_pattern, str(target)):
        res = target
    else:
        if non_native_flag:
            res = j2_env.from_string(str(target)).render(res_dict)
        else:
            res = j2_native_env.from_string(str(target)).render(res_dict)

        # unicodeを変更
        if not isinstance(res, list) and not isinstance(res, dict):
            res = str(res)

    return res

```

■機能
{{ simple_ref }} のような変数の値を取得する
■引数
all_dict(dict): 変数の参照先の候補
target(str): 対象となる変数の値(例、{{ simple_ref }})
■戻り値
対象の変数の値
■処理内容
targetの値が{{ simple_ref }} のような変数参照をしている場合にその変数の値を返します。
'{{ simple_int + simple_float + 10 }}'などの変数の値をsimple_int, simple_float, 10など、適宜分割し、最後に jinja2 のテンプレートとして[レンダリング](https://qiita.com/RyoMa_0923/items/528303906a55f9b568e0)します。

jinja2 の [NativeEnvironment](http://jinja.pocoo.org/docs/2.10/nativetypes/) では"aaa{{ simple }}11"といった数値や文字列が混じった場合のレンダリングがうまくいかないケースがあったので以下の部分で通常の Environment も利用するように場合分けしています(変数が配列や辞書でなければEnvironmentを利用します)。

```python
            if split in all_dict:
                res_dict[split] = recursive_update(all_dict, all_dict[split])
                # native_envで"aaa{{ hogehoge }}1"のような形式がうまく扱えなかったので別途対応
                if not re.match('^\[.*\]$|^\{.*\}$', str(res_dict[split])):
                    non_native_flag= True
# ----------------------------------- 中略 -----------------------------------
        if non_native_flag:
            res = j2_env.from_string(str(target)).render(res_dict)
        else:
            res = j2_native_env.from_string(str(target)).render(res_dict)
```

また、jinja2 の結果は unicode で返されるため、配列と辞書以外は str に型を変えます。

```python
        # unicodeを変更
        if not isinstance(res, list) and not isinstance(res, dict):
            res = str(res)
```

# 注意点
jinja2 のレンダリングを行う都合上、レンダリング自体が失敗するような場合には動作しなくなってしまいます。例えば、以下の場合には動作しないです。

* 変数にlookup などの[プラグイン](https://docs.ansible.com/ansible/latest/plugins/plugins.html)や[マジック変数](https://docs.ansible.com/ansible/latest/user_guide/playbooks_variables.html#accessing-information-about-other-hosts-with-magic-variables)、Ansible Vaultの値が含まれる場合
    * コード中で実施していますが、適宜レンダリングしないようにする必要があります。
* 多重に数値計算をする場合
    * {{ 変数名 }} の値を全て str で返しているので以下のような場合は失敗します。(sum が str 扱いになり、 sum2 の計算がうまくいかない)

```yml
simple_int: 10
sum: "{{ simple_int + 10 }}"
sum2: "{{ sum + 10 }}"
```

# その他
## hosts ファイルからグループの取得
本ページでは直接ymlファイルのパスを指定して変数の読み込みを行なっていますが、実際には引数や設定用のコンフィグファイルなど、何らかの形にしてパスを渡すことが多いです。中でも hosts ファイルに設定している[グループ毎に変数を設定をする](https://qiita.com/jumpyoshim/items/3b138d3ebbdaa873eaa5)こともあるため、hosts ファイルからグループの情報を取得する方法を一例を記載します。各ホストがどのグループに所属しているかの辞書を取得できます。

```python

def get_parent_group_dict('inventories/staging/hosts'):
    relation_dict = {}
    current_group = ''
    with open(file, 'r') as f:
        for line in f:
            line = line.rstrip('\n')
            if line.startswith('#') or line == '' :
                continue
            if line.startswith('[') and line.endswith(']'):
                current_group = re.sub('\[|\]', '', line.replace(':children', ''))
                continue

            line = line.split()[0]
            if line in relation_dict:
                relation_dict[line].append(current_group)
            else:
                relation_dict[line] = [current_group]

    parent_group_dict = {}
    for k, v in relation_dict.items():
        parent_list = []

        if hasinvalue(k, relation_dict):
            continue

        parse_relation_dict(parent_list, k, v, relation_dict)
        parent_group_dict[k] = parent_list

    return parent_group_dict


def hasinvalue(target, target_dict):
    result = False
    for v in target_dict.values():
        if target in v:
            result = True
            break

    return result


def parse_relation_dict(parent_list, target, group_list, relation_dict):
    for group in group_list:
        parent_list.append(group)
        if group in relation_dict:
            parse_relation_dict(parent_list, group, relation_dict[group], relation_dict)
```

本ページのタイトルから逸脱するので詳細は割愛します。

## Ansible Vaultの変数について
Ansible Vaultの変数は[ここら辺](https://qiita.com/yunano/items/5d3b6848456f3fa85270)の記事など見れば取得できるかと思いますが、Ansible Vaultの値を扱うこと自体に抵抗があったので、あえて値を取得しないようにしています。

# まとめ
要件に書いたようなようなymlファイルを読み取り、Ansibleで定義した変数の値を取得できました。ただし、当然定義していないプラグインやAnsibleのマジック変数の値を含む変数や多重に計算を行うような変数の取得はできません。
これを使う利点としては、Ansible以外のツール利用時に変数と同様の値を再定義しなくて良いことや、別途追加したい変数を別に追加しやすい(別にymlファイルを作成して読み込ませるようにするだけ)ことが挙げられます。

# 主な参考
* [Ansible Documentation](https://docs.ansible.com/ansible/latest/index.html)
    * [Ansible Best Practices](https://docs.ansible.com/ansible/latest/user_guide/playbooks_best_practices.html)
    * [Accessing information about other hosts with magic variables](https://docs.ansible.com/ansible/latest/user_guide/playbooks_variables.html#accessing-information-about-other-hosts-with-magic-variables)
    * [Working With Plugins](https://docs.ansible.com/ansible/latest/plugins/plugins.html)
* [Ansible2 変数の優先度](https://qiita.com/KeijiYONEDA/items/721407cbe418b3d532ed)
* [雑なJSONから特定のデータを取り出す](https://qiita.com/jumpyoshim/items/3b138d3ebbdaa873eaa5)
* [Ansibleでグループ毎に設定内容や処理を分岐する](https://qiita.com/smallpalace/items/dff5f602f6772c04360e)
* [Ansible Vaultに保存した値を標準出力に出力するワンライナー](https://qiita.com/yunano/items/5d3b6848456f3fa85270)